SuperCharge Claude Code v1.0.0 - Complete Customization Package

Features:
- 30+ Custom Skills (cognitive, development, UI/UX, autonomous agents)
- RalphLoop autonomous agent integration
- Multi-AI consultation (Qwen)
- Agent management system with sync capabilities
- Custom hooks for session management
- MCP servers integration
- Plugin marketplace setup
- Comprehensive installation script

Components:
- Skills: always-use-superpowers, ralph, brainstorming, ui-ux-pro-max, etc.
- Agents: 100+ agents across engineering, marketing, product, etc.
- Hooks: session-start-superpowers, qwen-consult, ralph-auto-trigger
- Commands: /brainstorm, /write-plan, /execute-plan
- MCP Servers: zai-mcp-server, web-search-prime, web-reader, zread
- Binaries: ralphloop wrapper

Installation: ./supercharge.sh
This commit is contained in:
uroma
2026-01-22 15:35:55 +00:00
Unverified
commit 7a491b1548
1013 changed files with 170070 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
# Agent Guidelines
A Claude Code / OpenCode plugin that blocks destructive git and filesystem commands before execution. Works as a PreToolUse hook intercepting Bash commands.
## Commands
| Task | Command |
|------|---------|
| Install | `bun install` |
| Build | `bun run build` |
| All checks | `bun run check` |
| Lint | `bun run lint` |
| Type check | `bun run typecheck` |
| Test all | `AGENT=1 bun test` |
| Single test | `bun test tests/rules-git.test.ts` |
| Pattern match | `bun test --test-name-pattern "pattern"` |
| Dead code | `bun run knip` |
| AST rules | `bun run sg:scan` |
**`bun run check`** runs: biome check → typecheck → knip → ast-grep scan → bun test
## Pre-commit Hooks
Runs on commit (in order): knip → lint-staged (biome check --write)
## Commit Conventions
When committing changes to files in `commands/`, `hooks/`, or `.opencode/`, use only `fix` or `feat` commit types. These directories contain user-facing skill definitions and hook configurations that represent features or fixes to the plugin's capabilities.
## Code Style (TypeScript)
### Formatting
- Formatter: Biome
- Line length: configured in `biome.json`
- Use tabs for indentation (Biome default)
### Type Hints
- **Required** on all functions
- Use `| null` or `| undefined` appropriately
- Use lowercase primitive types (`string`, `number`, `boolean`)
- Use `readonly` arrays where mutation isn't needed
```typescript
// Good
function analyze(command: string, options?: { strict?: boolean }): string | null { ... }
function analyzeRm(tokens: readonly string[], cwd: string | null): string | null { ... }
// Bad
function analyze(command, strict) { ... } // Missing types
```
### Imports
- Order: handled by Biome (sorted automatically)
- Use relative imports within same package
- Prefer named exports over default exports
```typescript
import { parse } from "shell-quote"
import type { Config, HookInput } from "../types"
import { analyzeGit } from "./rules-git"
import { splitShellCommands } from "./shell"
```
### Naming
- Functions/variables: `camelCase`
- Types/interfaces: `PascalCase`
- Constants: `UPPER_SNAKE_CASE` (reason strings: `REASON_*`)
- Private/internal: `_leadingUnderscore` (for module-private functions)
### Error Handling
- Print errors to stderr
- Return 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
```
| Module | Purpose |
|--------|---------|
| `index.ts` | OpenCode plugin export |
| `bin/cc-safety-net.ts` | Claude Code CLI wrapper, JSON I/O |
| `analyze.ts` | Main entry, command analysis orchestration |
| `config.ts` | Config loading (`.safety-net.json`), Config type |
| `rules-custom.ts` | Custom rule evaluation (`checkCustomRules`) |
| `rules-git.ts` | Git rules (checkout, restore, reset, clean, push, branch, stash) |
| `rules-rm.ts` | rm analysis (cwd-relative, temp paths, root/home detection) |
| `shell.ts` | Shell parsing (`splitShellCommands`, `shlexSplit`, `stripWrappers`) |
## Testing
Use Bun's built-in test runner with test helpers:
```typescript
import { describe, test } from "bun:test"
import { assertBlocked, assertAllowed } from "./helpers"
describe("git rules", () => {
test("git reset --hard blocked", () => {
assertBlocked("git reset --hard", "git reset --hard")
})
test("git status allowed", () => {
assertAllowed("git status")
})
test("with cwd", () => {
assertBlocked("rm -rf /", "rm -rf", "/home/user")
})
})
```
### Test Helpers
| Function | Purpose |
|----------|---------|
| `assertBlocked(command, reasonContains, cwd?)` | Verify command is blocked |
| `assertAllowed(command, cwd?)` | Verify command passes through |
| `runGuard(command, cwd?, config?)` | Run analysis and return reason or null |
| `withEnv(env, fn)` | Run test with temporary environment variables |
## Environment Variables
| Variable | Effect |
|----------|--------|
| `SAFETY_NET_STRICT=1` | Fail-closed on unparseable hook input/commands |
| `SAFETY_NET_PARANOID=1` | Enable all paranoid checks (rm + interpreters) |
| `SAFETY_NET_PARANOID_RM=1` | Block non-temp `rm -rf` even within the current working directory |
| `SAFETY_NET_PARANOID_INTERPRETERS=1` | Block interpreter one-liners like `python -c`, `node -e`, etc. |
## What Gets Blocked
**Git**: `checkout -- <files>`, `restore` (without --staged), `reset --hard/--merge`, `clean -f`, `push --force/-f` (without --force-with-lease), `branch -D`, `stash drop/clear`
**Filesystem**: `rm -rf` outside cwd (except `/tmp`, `/var/tmp`, `$TMPDIR`), `rm -rf` when cwd is `$HOME`, `rm -rf /` or `~`, `find -delete`
**Piped commands**: `xargs rm -rf`, `parallel rm -rf` (dynamic input to destructive commands)
## Adding New Rules
### Git Rule
1. Add reason constant in `rules-git.ts`: `const REASON_* = "..."`
2. Add detection logic in `analyzeGit()`
3. Add tests in `tests/rules-git.test.ts`
4. Run `bun run check`
### rm Rule
1. Add logic in `rules-rm.ts`
2. Add tests in `tests/rules-rm.test.ts`
3. Run `bun run check`
### Other Command Rules
1. Add reason constant in `analyze.ts`: `const REASON_* = "..."`
2. Add detection in `analyzeSegment()`
3. Add tests in appropriate test file
4. Run `bun run check`
## Edge Cases to Test
- Shell wrappers: `bash -c '...'`, `sh -lc '...'`
- Sudo/env: `sudo git ...`, `env VAR=1 git ...`
- Pipelines: `echo ok | git reset --hard`
- Interpreter one-liners: `python -c 'os.system("rm -rf /")'`
- Xargs/parallel: `find . | xargs rm -rf`
- Busybox: `busybox rm -rf /`
- Nested commands: `$( rm -rf / )`, backticks
## Hook Output Format
Blocked commands produce JSON:
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "BLOCKED by Safety Net\n\nReason: ..."
}
}
```
Allowed commands produce no output (exit 0 silently).
## Bun Guidelines
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Bun.$`ls` instead of execa.
## Testing
Use `AGENT=1 bun test` to run tests.
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

View File

@@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
A Claude Code and OpenCode plugin that blocks destructive git and filesystem commands before execution. It works as a PreToolUse hook that intercepts Bash commands and denies dangerous operations like `git reset --hard`, `rm -rf`, and `git checkout -- <files>`.
## Commands
- **Setup**: `bun install`
- **All checks**: `bun run check` (runs lint, typecheck, knip, ast-grep scan, tests)
- **Single test**: `bun test tests/file.test.ts`
- **Lint**: `bun run lint` (uses Biome)
- **Type check**: `bun run typecheck`
- **Dead code**: `bun run knip`
- **AST scan**: `bun run sg:scan`
- **Build**: `bun run build`
## Architecture
The hook receives JSON input on stdin containing `tool_name` and `tool_input`. For `Bash` tools, it analyzes the command and outputs JSON with `permissionDecision: "deny"` to block dangerous operations.
**Entry points**:
- `src/bin/cc-safety-net.ts` — Claude Code CLI (reads stdin JSON)
- `src/index.ts` — OpenCode plugin export
**Core analysis flow**:
1. `cc-safety-net.ts:main()` parses JSON input, extracts command
2. `analyze.ts:analyzeCommand()` splits command on shell operators (`;`, `&&`, `|`, etc.)
3. `analyzeSegment()` tokenizes each segment, strips wrappers (sudo, env), identifies the command
4. Dispatches to `rules-git.ts:analyzeGit()` or `rules-rm.ts:analyzeRm()` based on command
5. Checks custom rules via `rules-custom.ts:checkCustomRules()` if configured
**Key modules** (`src/core/`):
- `shell.ts`: Shell parsing (`splitShellCommands`, `shlexSplit`, `stripWrappers`, `shortOpts`)
- `rules-git.ts`: Git subcommand analysis (checkout, restore, reset, clean, push, branch, stash)
- `rules-rm.ts`: rm analysis (allows rm -rf within cwd except when cwd is $HOME; temp paths always allowed; strict mode blocks non-temp)
- `config.ts`: Config loading, validation, merging (user `~/.cc-safety-net/config.json` + project `.safety-net.json`)
- `rules-custom.ts`: Custom rule matching (`checkCustomRules()`)
- `audit.ts`: Audit logging for blocked commands
- `verify-config.ts`: Config validator
**Test utilities** (`tests/helpers.ts`):
- `assertBlocked()`, `assertAllowed()` helpers for testing command analysis
**Advanced detection**:
- Recursively analyzes shell wrappers (`bash -c '...'`) up to 5 levels deep
- Detects destructive commands in interpreter one-liners (`python -c`, `node -e`, `ruby -e`, `perl -e`)
- Handles `xargs` and `parallel` with template expansion and dynamic input detection
- Detects `find -delete` and `find -exec rm` patterns
- Redacts secrets (tokens, passwords, API keys) in block messages and audit logs
- Audit logging: blocked commands logged to `~/.cc-safety-net/logs/<session_id>.jsonl`
## Code Style (TypeScript)
- Use Bun instead of Node.js for running, testing, and building
- Biome for linting and formatting
- All functions require type annotations
- Use `type | null` syntax (not `undefined` where possible)
- Use kebab-case for file names (`rules-git.ts`, not `rulesGit.ts`)
## Commit Conventions
When committing changes to files in `commands/`, `hooks/`, or `.opencode/`, use only `fix` or `feat` commit types. These directories contain user-facing skill definitions and hook configurations that represent features or fixes to the plugin's capabilities.
## Environment Variables
- `SAFETY_NET_STRICT=1`: Strict mode (fail-closed on unparseable hook input/commands)
- `SAFETY_NET_PARANOID=1`: Paranoid mode (enables all paranoid checks)
- `SAFETY_NET_PARANOID_RM=1`: Paranoid rm (blocks non-temp `rm -rf` even within cwd)
- `SAFETY_NET_PARANOID_INTERPRETERS=1`: Paranoid interpreters (blocks interpreter one-liners)
## Custom Rules
Users can define additional blocking rules in two scopes (merged, project overrides user):
- **User scope**: `~/.cc-safety-net/config.json` (applies to all projects)
- **Project scope**: `.safety-net.json` (in project root)
Rules are additive only—cannot bypass built-in protections. Invalid config silently falls back to built-in rules only.
## Testing
Use `AGENT=1 bun test` to run tests.
## Bun Best Practices
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install`
- Use `bun run <script>` instead of `npm run <script>`
- Bun automatically loads .env, so don't use dotenv
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

View File

@@ -0,0 +1,422 @@
# Contributing to Claude Code Safety Net
First off, thanks for taking the time to contribute! This document provides guidelines and instructions for contributing to cc-safety-net.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Before You Start: Proposing New Features](#before-you-start-proposing-new-features)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Development Setup](#development-setup)
- [Testing Your Changes Locally](#testing-your-changes-locally)
- [Project Structure](#project-structure)
- [Development Workflow](#development-workflow)
- [Build Commands](#build-commands)
- [Code Style & Conventions](#code-style--conventions)
- [Making Changes](#making-changes)
- [Adding a Git Rule](#adding-a-git-rule)
- [Adding an rm Rule](#adding-an-rm-rule)
- [Adding Other Command Rules](#adding-other-command-rules)
- [Pull Request Process](#pull-request-process)
- [Publishing](#publishing)
- [Getting Help](#getting-help)
## Code of Conduct
Be respectful, inclusive, and constructive. We're all here to make better tools together.
## Before You Start: Proposing New Features
**Please open an issue to discuss new features before implementing them.**
This project has a focused scope: **preventing coding agents from making accidental mistakes that cause data loss** (e.g., `rm -rf ~/`, `git reset --hard`). It is NOT a general security hardening tool or an attack prevention system.
### Why Discuss First?
1. **Scope alignment** — Your idea might be great but outside the project's scope
2. **Approach feedback** — We can suggest the best way to implement it
3. **Avoid wasted effort** — Save time for both you and maintainers
### When to Open an Issue First
| Scenario | Open Issue First? |
|----------|-------------------|
| New detection rule (git, rm, etc.) | **Yes** |
| New command category to block | **Yes** |
| Architectural changes | **Yes** |
| New configuration options | **Yes** |
| Typo/documentation fixes | No, just PR |
| Small bug fixes with obvious solution | No, just PR |
### What to Include in Your Proposal
- **What** you want to add/change
- **Why** it fits the project scope (preventing accidental data loss)
- **Real-world scenario** where this would help
- Any **trade-offs** you've considered
A quick 5-minute issue can save hours of implementation time on both sides.
## Getting Started
### Prerequisites
- **Bun** - Required runtime and package manager ([install guide](https://bun.sh/docs/installation))
- **Claude Code** or **OpenCode** - For testing the plugin
### Development Setup
```bash
# Clone the repository
git clone https://github.com/kenryu42/claude-code-safety-net.git
cd claude-code-safety-net
# Install dependencies
bun install
# Build for distribution
bun run build
# Check for all lint errors, type errors, dead code and run tests
bun run check
```
### Testing Your Changes Locally
## Claude Code
1. **Build the project**:
```bash
bun run build
```
2. **Disable the safety-net plugin** in Claude Code (if installed) and exit Claude Code completely.
3. **Run Claude Code with the local plugin**:
```bash
claude --plugin-dir .
```
4. **Test blocked commands** to verify your changes:
```bash
# This should be blocked
git checkout -- README.md
# This should be allowed
git checkout -b test-branch
```
> [!NOTE]
> See the [official documentation](https://docs.anthropic.com/en/docs/claude-code/plugins#test-your-plugins-locally) for more details on testing plugins locally.
## OpenCode
1. **Build the project**:
```bash
bun run build
```
2. **Update your OpenCode config** (`~/.config/opencode/opencode.json` or `opencode.jsonc`):
```json
{
"plugin": [
"file:///absolute/path/to/cc-safety-net/dist/index.js"
]
}
```
For example, if your project is at `/Users/yourname/projects/cc-safety-net`:
```json
{
"plugin": [
"file:///Users/yourname/projects/cc-safety-net/dist/index.js"
]
}
```
> [!NOTE]
> Remove `"cc-safety-net"` from the plugin array if it exists, to avoid conflicts with the npm version.
> Or comment out the line if you're using `opencode.jsonc`.
3. **Restart OpenCode** to load the changes.
4. **Verify the plugin is loaded:** Run `/status` and confirm that the plugin name appears as `dist`.
5. **Test blocked commands** to verify your changes:
```bash
# This should be blocked
git checkout -- README.md
# This should be allowed
git checkout -b test-branch
```
> [!NOTE]
> See the [official documentation](https://opencode.ai/docs/plugins/) for more details on OpenCode plugins.
## Project Structure
```
claude-code-safety-net/
├── .claude-plugin/
│ ├── plugin.json # Plugin metadata
│ └── marketplace.json # Marketplace config
├── .github/
│ ├── workflows/ # CI/CD workflows
│ │ ├── ci.yml
│ │ ├── lint-github-actions-workflows.yml
│ │ └── publish.yml
│ └── pull_request_template.md
├── .husky/
│ └── pre-commit # Pre-commit hook (knip + lint-staged)
├── assets/
│ └── cc-safety-net.schema.json # JSON schema for config validation
├── ast-grep/
│ ├── rules/ # AST-grep rule definitions
│ ├── rule-tests/ # Rule test cases
│ └── utils/ # Shared utilities
├── commands/
│ ├── set-custom-rules.md # Slash command: configure custom rules
│ └── verify-custom-rules.md # Slash command: validate config
├── hooks/
│ └── hooks.json # Hook definitions
├── scripts/
│ ├── build-schema.ts # Generate JSON schema
│ ├── generate-changelog.ts # Changelog generation
│ └── publish.ts # Release automation
├── src/
│ ├── index.ts # OpenCode plugin export
│ ├── types.ts # Shared type definitions
│ ├── bin/
│ │ └── cc-safety-net.ts # Claude Code CLI entry point
│ └── core/
│ ├── analyze.ts # Main analysis orchestration
│ ├── analyze/ # Analysis submodules
│ │ ├── analyze-command.ts # Command analysis entry
│ │ ├── constants.ts # Shared constants
│ │ ├── dangerous-text.ts # Text pattern detection
│ │ ├── find.ts # find command analysis
│ │ ├── interpreters.ts # Interpreter one-liner detection
│ │ ├── parallel.ts # parallel command analysis
│ │ ├── rm-flags.ts # rm flag parsing
│ │ ├── segment.ts # Command segment analysis
│ │ ├── shell-wrappers.ts # Shell wrapper detection
│ │ ├── tmpdir.ts # Temp directory detection
│ │ └── xargs.ts # xargs command analysis
│ ├── audit.ts # Audit logging
│ ├── config.ts # Config loading
│ ├── custom-rules-doc.ts # Custom rules documentation
│ ├── env.ts # Environment variable utilities
│ ├── format.ts # Output formatting
│ ├── rules-git.ts # Git subcommand analysis
│ ├── rules-rm.ts # rm command analysis
│ ├── rules-custom.ts # Custom rule evaluation
│ ├── shell.ts # Shell parsing utilities
│ └── verify-config.ts # Config validator
├── tests/
│ ├── helpers.ts # Test utilities
│ ├── analyze-coverage.test.ts
│ ├── audit.test.ts
│ ├── config.test.ts
│ ├── custom-rules.test.ts
│ ├── custom-rules-integration.test.ts
│ ├── edge-cases.test.ts
│ ├── find.test.ts
│ ├── parsing-helpers.test.ts
│ ├── rules-git.test.ts
│ ├── rules-rm.test.ts
│ └── verify-config.test.ts
├── .lintstagedrc.json # Lint-staged config (biome + ast-grep)
├── biome.json # Linter/formatter config
├── knip.ts # Dead code detection config
├── package.json # Project config
├── sgconfig.yml # AST-grep config
├── tsconfig.json # TypeScript config
├── tsconfig.typecheck.json # Type-check only config
├── AGENTS.md # AI agent guidelines
├── CLAUDE.md # Claude Code context
└── README.md # Project documentation
```
| Module | Purpose |
|--------|---------|
| `analyze.ts` | Main entry, command analysis orchestration |
| `analyze/` | Submodules for specific analysis tasks (find, xargs, parallel, interpreters, etc.) |
| `audit.ts` | Audit logging to `~/.cc-safety-net/logs/` |
| `config.ts` | Config loading (`.safety-net.json`, `~/.cc-safety-net/config.json`) |
| `env.ts` | Environment variable utilities (`envTruthy`) |
| `format.ts` | Output formatting (`formatBlockedMessage`) |
| `rules-git.ts` | Git rules (checkout, restore, reset, clean, push, branch, stash) |
| `rules-rm.ts` | rm analysis (cwd-relative, temp paths, root/home detection) |
| `rules-custom.ts` | Custom rule matching |
| `shell.ts` | Shell parsing (`splitShellCommands`, `shlexSplit`, `stripWrappers`) |
| `verify-config.ts` | Config file validation |
## Development Workflow
### Build Commands
```bash
# Run all checks (lint, type check, dead code, ast-grep scan, tests)
bun run check
# Individual commands
bun run lint # Lint + format (Biome)
bun run typecheck # Type check
bun run knip # Dead code detection
bun run sg:scan # AST pattern scan
bun test # Run tests
# Run specific test
bun test tests/rules-git.test.ts
# Run tests matching pattern
bun test --test-name-pattern "checkout"
# Build for distribution
bun run build
```
### Code Style & Conventions
| Convention | Rule |
|------------|------|
| Runtime | **Bun** |
| Package Manager | **bun only** (`bun install`, `bun run`) |
| Formatter/Linter | **Biome** |
| Type Hints | Required on all functions |
| Type Syntax | `type \| null` preferred over `type \| undefined` |
| File Naming | `kebab-case` (e.g., `rules-git.ts`, not `rulesGit.ts`) |
| Function Naming | `camelCase` for functions, `PascalCase` for types/interfaces |
| Constants | `SCREAMING_SNAKE_CASE` for reason constants |
| Imports | Relative imports within package |
**Examples**:
```typescript
// Good
export function analyzeCommand(
command: string,
options?: { strict?: boolean }
): string | null {
// ...
}
// Bad
export function analyzeCommand(command, options) { // Missing type hints
// ...
}
```
**Anti-Patterns (Do Not Do)**:
- Using npm/yarn/pnpm instead of bun
- Suppressing type errors with `@ts-ignore` or `any`
- Skipping tests for new rules
- Modifying version in `package.json` directly
## Making Changes
### Adding a Git Rule
1. **Add reason constant** in `src/core/rules-git.ts`:
```typescript
const REASON_MY_RULE = "git my-command does something dangerous. Use safer alternative.";
```
2. **Add detection logic** in `analyzeGit()`:
```typescript
if (subcommand === "my-command" && tokens.includes("--dangerous-flag")) {
return REASON_MY_RULE;
}
```
3. **Add tests** in `tests/rules-git.test.ts`:
```typescript
describe("git my-command", () => {
test("dangerous flag blocked", () => {
assertBlocked("git my-command --dangerous-flag", "dangerous");
});
test("safe flag allowed", () => {
assertAllowed("git my-command --safe-flag");
});
});
```
4. **Run checks**:
```bash
bun run check
```
### Adding an rm Rule
1. **Add logic** in `src/core/rules-rm.ts`
2. **Add tests** in `tests/rules-rm.test.ts`
3. **Run checks**: `bun run check`
### Adding Other Command Rules
1. **Add reason constant** in `src/core/analyze.ts`:
```typescript
const REASON_MY_COMMAND = "my-command is dangerous because...";
```
2. **Add detection** in `analyzeSegment()`
3. **Add tests** in the appropriate test file
4. **Run checks**: `bun run check`
### Edge Cases to Test
When adding rules, ensure you test these edge cases:
- Shell wrappers: `bash -c '...'`, `sh -lc '...'`
- Sudo/env prefixes: `sudo git ...`, `env VAR=1 git ...`
- Pipelines: `echo ok | git reset --hard`
- Interpreter one-liners: `python -c 'os.system("...")'`
- Xargs/parallel: `find . | xargs rm -rf`
- Busybox: `busybox rm -rf /`
## Pull Request Process
1. **Fork** the repository and create your branch from `main`
2. **Make changes** following the conventions above
3. **Run all checks** locally:
```bash
bun run check # Must pass with no errors
```
4. **Test in Claude Code and OpenCode** using the local plugin method described above
5. **Commit** with clear, descriptive messages:
- Use present tense ("Add rule" not "Added rule")
- Reference issues if applicable ("Fix #123")
6. **Push** to your fork and create a Pull Request
7. **Describe** your changes clearly in the PR description
### PR Checklist
- [ ] Code follows project conventions (type hints, naming, etc.)
- [ ] `bun run check` passes (lint, types, dead code, tests)
- [ ] Tests added for new rules
- [ ] Tested locally with Claude Code and Opencode
- [ ] Updated documentation if needed (README, AGENTS.md)
- [ ] No version changes in `package.json`
## Publishing
**Important**: Version bumping and releases are handled by maintainers only.
- **Never** modify the version in `package.json` or `plugin.json` directly
- Maintainers handle versioning, tagging, and releases
## Getting Help
- **Project Knowledge**: Check `CLAUDE.md` or `AGENTS.md` for detailed architecture and conventions
- **Code Patterns**: Review existing implementations in `src/core/`
- **Test Patterns**: See `tests/helpers.ts` for test utilities
- **Issues**: Open an issue for bugs or feature requests
---
Thank you for contributing to Claude Code Safety Net! Your efforts help keep AI-assisted coding safer for everyone.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kenryu42
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,587 @@
# Claude Code Safety Net
[![CI](https://github.com/kenryu42/claude-code-safety-net/actions/workflows/ci.yml/badge.svg)](https://github.com/kenryu42/claude-code-safety-net/actions/workflows/ci.yml)
[![codecov](https://codecov.io/github/kenryu42/claude-code-safety-net/branch/main/graph/badge.svg?token=C9QTION6ZF)](https://codecov.io/github/kenryu42/claude-code-safety-net)
[![Version](https://img.shields.io/github/v/tag/kenryu42/claude-code-safety-net?label=version&color=blue)](https://github.com/kenryu42/claude-code-safety-net)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-D27656)](#claude-code-installation)
[![OpenCode](https://img.shields.io/badge/OpenCode-black)](#opencode-installation)
[![Gemini CLI](https://img.shields.io/badge/Gemini%20CLI-678AE3)](#gemini-cli-installation)
[![License: MIT](https://img.shields.io/badge/License-MIT-red.svg)](https://opensource.org/licenses/MIT)
<div align="center">
[![CC Safety Net](./.github/assets/cc-safety-net.png)](./.github/assets/cc-safety-net.png)
</div>
A Claude Code plugin that acts as a safety net, catching destructive git and filesystem commands before they execute.
## Contents
- [Why This Exists](#why-this-exists)
- [Why Use This Instead of Permission Deny Rules?](#why-use-this-instead-of-permission-deny-rules)
- [What About Sandboxing?](#what-about-sandboxing)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Claude Code Installation](#claude-code-installation)
- [OpenCode Installation](#opencode-installation)
- [Gemini CLI Installation](#gemini-cli-installation)
- [Status Line Integration](#status-line-integration)
- [Setup via Slash Command](#setup-via-slash-command)
- [Manual Setup](#manual-setup)
- [Emoji Mode Indicators](#emoji-mode-indicators)
- [Commands Blocked](#commands-blocked)
- [Commands Allowed](#commands-allowed)
- [What Happens When Blocked](#what-happens-when-blocked)
- [Testing the Hook](#testing-the-hook)
- [Development](#development)
- [Custom Rules (Experimental)](#custom-rules-experimental)
- [Config File Location](#config-file-location)
- [Rule Schema](#rule-schema)
- [Matching Behavior](#matching-behavior)
- [Examples](#examples)
- [Error Handling](#error-handling)
- [Advanced Features](#advanced-features)
- [Strict Mode](#strict-mode)
- [Paranoid Mode](#paranoid-mode)
- [Shell Wrapper Detection](#shell-wrapper-detection)
- [Interpreter One-Liner Detection](#interpreter-one-liner-detection)
- [Secret Redaction](#secret-redaction)
- [Audit Logging](#audit-logging)
- [License](#license)
## Why This Exists
We learned the [hard way](https://www.reddit.com/r/ClaudeAI/comments/1pgxckk/claude_cli_deleted_my_entire_home_directory_wiped/) that instructions aren't enough to keep AI agents in check.
After Claude Code silently wiped out hours of progress with a single `rm -rf ~/` or `git checkout --`, it became evident that **soft** rules in an `CLAUDE.md` or `AGENTS.md` file cannot replace **hard** technical constraints.
The current approach is to use a dedicated hook to programmatically prevent agents from running destructive commands.
## Why Use This Instead of Permission Deny Rules?
Claude Code's `.claude/settings.json` supports [deny rules](https://code.claude.com/docs/en/iam#tool-specific-permission-rules) with wildcard matching (e.g., `Bash(git reset --hard:*)`). Here's how this plugin differs:
### At a Glance
| | Permission Deny Rules | Safety Net |
|---|---|---|
| **Setup** | Manual configuration required | Works out of the box |
| **Parsing** | Wildcard pattern matching | Semantic command analysis |
| **Execution order** | Runs second | Runs first (PreToolUse hook) |
| **Shell wrappers** | Not handled automatically (must match wrapper forms) | Recursively analyzed (5 levels) |
| **Interpreter one-liners** | Not handled automatically (must match interpreter forms) | Detected and blocked |
### Permission Rules Have Known Bypass Vectors
Even with wildcard matching, Bash permission patterns are intentionally limited and can be bypassed in many ways:
| Bypass Method | Example |
|---------------|---------|
| Options before value | `curl -X GET http://evil.com` bypasses `Bash(curl http://evil.com:*)` |
| Shell variables | `URL=http://evil.com && curl $URL` bypasses URL pattern |
| Flag reordering | `rm -r -f /` bypasses `Bash(rm -rf:*)` |
| Extra whitespace | `rm -rf /` (double space) bypasses pattern |
| Shell wrappers | `sh -c "rm -rf /"` bypasses `Bash(rm:*)` entirely |
### Safety Net Handles What Patterns Can't
| Scenario | Permission Rules | Safety Net |
|----------|------------------|------------|
| `git checkout -b feature` (safe) | Blocked by `Bash(git checkout:*)` | Allowed |
| `git checkout -- file` (dangerous) | Blocked by `Bash(git checkout:*)` | Blocked |
| `rm -rf /tmp/cache` (safe) | Blocked by `Bash(rm -rf:*)` | Allowed |
| `rm -r -f /` (dangerous) | Allowed (flag order) | Blocked |
| `bash -c 'git reset --hard'` | Allowed (wrapper) | Blocked |
| `python -c 'os.system("rm -rf /")'` | Allowed (interpreter) | Blocked |
### Defense in Depth
PreToolUse hooks run [**before**](https://code.claude.com/docs/en/iam#additional-permission-control-with-hooks) the permission system. This means Safety Net inspects every command first, regardless of your permission configuration. Even if you misconfigure deny rules, Safety Net provides a fallback layer of protection.
**Use both together**: Permission deny rules for quick, user-configurable blocks; Safety Net for robust, bypass-resistant protection that works out of the box.
## What About Sandboxing?
Claude Code offers [native sandboxing](https://code.claude.com/docs/en/sandboxing) that provides OS-level filesystem and network isolation. Here's how it compares to Safety Net:
### Different Layers of Protection
| | Sandboxing | Safety Net |
|---|---|---|
| **Enforcement** | OS-level (Seatbelt/bubblewrap) | Application-level (PreToolUse hook) |
| **Approach** | Containment — restricts filesystem + network access | Command analysis — blocks destructive operations |
| **Filesystem** | Writes restricted (default: cwd); reads are broad by default | Only destructive operations blocked |
| **Network** | Domain-based proxy filtering | None |
| **Git awareness** | None | Explicit rules for destructive git operations |
| **Bypass resistance** | High — OS enforces boundaries | Lower — analyzes command strings only |
### Why Sandboxing Isn't Enough
Sandboxing restricts filesystem + network access, but it doesn't understand whether an operation is destructive within those boundaries. These commands are not blocked by the sandbox boundary:
> [!NOTE]
> Whether they're auto-run or require confirmation depends on your sandbox mode (auto-allow vs regular permissions), and network access still depends on your allowed-domain policy. Claude Code can also retry a command outside the sandbox via `dangerouslyDisableSandbox` (with user permission); this can be disabled with `allowUnsandboxedCommands: false`.
| Command | Sandboxing | Safety Net |
|---------|------------|------------|
| `git reset --hard` | Allowed (within cwd) | **Blocked** |
| `git checkout -- .` | Allowed (within cwd) | **Blocked** |
| `git stash clear` | Allowed (within cwd) | **Blocked** |
| `git push --force` | Allowed (if remote domain is allowed) | **Blocked** |
| `rm -rf .` | Allowed (within cwd) | **Blocked** |
Sandboxing sees `git reset --hard` as a safe operation—it only modifies files within the current directory. But you just lost all uncommitted work.
### When to Use Sandboxing Instead
Sandboxing is the better choice when your primary concern is:
- **Prompt injection attacks** — Reduces exfiltration risk by restricting outbound domains (depends on your allowed-domain policy)
- **Malicious dependencies** — Limits filesystem writes and network access by default (subject to your sandbox configuration)
- **Untrusted code execution** — OS-level containment is stronger than pattern matching
- **Network control** — Safety Net has no network protection
### Recommended: Use Both
They protect against different threats:
- **Sandboxing** contains blast radius — even if something goes wrong, damage is limited to cwd and approved network domains
- **Safety Net** prevents footguns — catches git-specific mistakes that are technically "safe" from the sandbox's perspective
Running both together provides defense-in-depth. Sandboxing handles unknown threats; Safety Net handles known destructive patterns that sandboxing permits.
## Prerequisites
- **Node.js**: Version 18 or higher is required to run this plugin
## Quick Start
### Claude Code Installation
```bash
/plugin marketplace add kenryu42/cc-marketplace
/plugin install safety-net@cc-marketplace
```
> [!NOTE]
> After installing the plugin, you need to restart your Claude Code for it to take effect.
### Claude Code Auto-Update
1. Run `/plugin` → Select `Marketplaces` → Choose `cc-marketplace` → Enable auto-update
---
### OpenCode Installation
**Option A: Let an LLM do it**
Paste this into any LLM agent (Claude Code, OpenCode, Cursor, etc.):
```
Install the cc-safety-net plugin in `~/.config/opencode/opencode.json` (or `.jsonc`) according to the schema at: https://opencode.ai/config.json
```
**Option B: Manual setup**
1. **Add the plugin to your config** `~/.config/opencode/opencode.json` (or `.jsonc`):
```json
{
"plugin": ["cc-safety-net"]
}
```
---
### Gemini CLI Installation
```bash
gemini extensions install https://github.com/kenryu42/gemini-safety-net
```
> [!IMPORTANT]
> You need to set the following settings in `.gemini/settings.json` to enable hooks:
> ```json
> {
> "tools": {
> "enableHooks": true
> }
> }
> ```
## Status Line Integration
Safety Net can display its status in Claude Code's status line, showing whether protection is active and which modes are enabled.
### Setup via Slash Command
The easiest way to configure the status line is using the built-in slash command:
```
/set-statusline
```
This interactive command will:
1. Ask whether you prefer `bunx` or `npx`
2. Check for existing status line configuration
3. Offer to replace or pipe with existing commands
4. Write the configuration to `~/.claude/settings.json`
### Manual Setup
Add the following to your `~/.claude/settings.json`:
**Using Bun (recommended):**
```json
{
"statusLine": {
"type": "command",
"command": "bunx cc-safety-net --statusline"
}
}
```
**Using npm:**
```json
{
"statusLine": {
"type": "command",
"command": "npx -y cc-safety-net --statusline"
}
}
```
**Piping with existing status line:**
If you already have a status line command, you can pipe Safety Net at the end:
```json
{
"statusLine": {
"type": "command",
"command": "your-existing-command | bunx cc-safety-net --statusline"
}
}
```
Changes take effect immediately — no restart needed.
### Emoji Mode Indicators
The status line displays different emojis based on the current configuration:
| Status | Display | Meaning |
|--------|---------|---------|
| Plugin disabled | `🛡️ Safety Net ❌` | Safety Net plugin is not enabled |
| Default mode | `🛡️ Safety Net ✅` | Protection active with default settings |
| Strict mode | `🛡️ Safety Net 🔒` | `SAFETY_NET_STRICT=1` — fail-closed on unparseable commands |
| Paranoid mode | `🛡️ Safety Net 👁️` | `SAFETY_NET_PARANOID=1` — all paranoid checks enabled |
| Paranoid RM only | `🛡️ Safety Net 🗑️` | `SAFETY_NET_PARANOID_RM=1` — blocks `rm -rf` even within cwd |
| Paranoid interpreters only | `🛡️ Safety Net 🐚` | `SAFETY_NET_PARANOID_INTERPRETERS=1` — blocks interpreter one-liners |
| Strict + Paranoid | `🛡️ Safety Net 🔒👁️` | Both strict and paranoid modes enabled |
Multiple mode emojis are combined when multiple environment variables are set.
## Commands Blocked
| Command Pattern | Why It's Dangerous |
|-----------------|-------------------|
| git checkout -- files | Discards uncommitted changes permanently |
| git checkout \<ref\> -- \<path\> | Overwrites working tree with ref version |
| git restore files | Discards uncommitted changes |
| git restore --worktree | Explicitly discards working tree changes |
| git reset --hard | Destroys all uncommitted changes |
| git reset --merge | Can lose uncommitted changes |
| git clean -f | Removes untracked files permanently |
| git push --force / -f | Destroys remote history |
| git branch -D | Force-deletes branch without merge check |
| git stash drop | Permanently deletes stashed changes |
| git stash clear | Deletes ALL stashed changes |
| git worktree remove --force | Force-deletes worktree without checking for changes |
| rm -rf (paths outside cwd) | Recursive file deletion outside the current directory |
| rm -rf / or ~ or $HOME | Root/home deletion is extremely dangerous |
| find ... -delete | Permanently removes files matching criteria |
| xargs rm -rf | Dynamic input makes targets unpredictable |
| xargs \<shell\> -c | Can execute arbitrary commands |
| parallel rm -rf | Dynamic input makes targets unpredictable |
| parallel \<shell\> -c | Can execute arbitrary commands |
## Commands Allowed
| Command Pattern | Why It's Safe |
|-----------------|--------------|
| git checkout -b branch | Creates new branch |
| git checkout --orphan | Creates orphan branch |
| git restore --staged | Only unstages, doesn't discard |
| git restore --help/--version | Help/version output |
| git branch -d | Safe delete with merge check |
| git clean -n / --dry-run | Preview only |
| git push --force-with-lease | Safe force push |
| rm -rf /tmp/... | Temp directories are ephemeral |
| rm -rf /var/tmp/... | System temp directory |
| rm -rf $TMPDIR/... | User's temp directory |
| rm -rf ./... (within cwd) | Limited to current working directory |
## What Happens When Blocked
When a destructive command is detected, the plugin blocks the tool execution and provides a reason.
Example output:
```text
BLOCKED by Safety Net
Reason: git checkout -- discards uncommitted changes permanently. Use 'git stash' first.
Command: git checkout -- src/main.py
If this operation is truly needed, ask the user for explicit permission and have them run the command manually.
```
## Testing the Hook
You can manually test the hook by attempting to run blocked commands in Claude Code:
```bash
# This should be blocked
git checkout -- README.md
# This should be allowed
git checkout -b test-branch
```
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to this project.
## Custom Rules (Experimental)
Beyond the built-in protections, you can define your own blocking rules to enforce team conventions or project-specific safety policies.
> [!TIP]
> Use `/set-custom-rules` to create custom rules interactively with natural language.
### Quick Example
Create `.safety-net.json` in your project root:
```json
{
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", "."],
"reason": "Use 'git add <specific-files>' instead of blanket add."
}
]
}
```
Now `git add -A`, `git add --all`, and `git add .` will be blocked with your custom message.
### Config File Location
Config files are loaded from two scopes and merged:
1. **User scope**: `~/.cc-safety-net/config.json` (always loaded if exists)
2. **Project scope**: `.safety-net.json` in the current working directory (loaded if exists)
**Merging behavior**:
- Rules from both scopes are combined
- If the same rule name exists in both scopes, **project scope wins**
- Rule name comparison is case-insensitive (`MyRule` and `myrule` are considered duplicates)
This allows you to define personal defaults in user scope while letting projects override specific rules.
If no config file is found in either location, only built-in rules apply.
### Config Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | Yes | Schema version (must be `1`) |
| `rules` | array | No | List of custom blocking rules (defaults to empty) |
### Rule Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Unique identifier (letters, numbers, hyphens, underscores; max 64 chars) |
| `command` | string | Yes | Base command to match (e.g., `git`, `npm`, `docker`) |
| `subcommand` | string | No | Subcommand to match (e.g., `add`, `install`). If omitted, matches any. |
| `block_args` | array | Yes | Arguments that trigger the block (at least one required) |
| `reason` | string | Yes | Message shown when blocked (max 256 chars) |
### Matching Behavior
- **Commands** are normalized to basename (`/usr/bin/git` → `git`)
- **Subcommand** is the first non-option argument after the command
- **Arguments** are matched literally (no regex, no glob), with short option expansion
- A command is blocked if **any** argument in `block_args` is present
- **Short options** are expanded: `-Ap` matches `-A` (bundled flags are unbundled)
- **Long options** use exact match: `--all-files` does NOT match `--all`
- Custom rules only add restrictions—they cannot bypass built-in protections
#### Known Limitations
- **Short option expansion**: `-Cfoo` is treated as `-C -f -o -o`, not `-C foo`. Blocking `-f` may false-positive on attached option values.
### Examples
#### Block global npm installs
```json
{
"version": 1,
"rules": [
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Global npm installs can cause version conflicts. Use npx or local install."
}
]
}
```
#### Block dangerous docker commands
```json
{
"version": 1,
"rules": [
{
"name": "block-docker-system-prune",
"command": "docker",
"subcommand": "system",
"block_args": ["prune"],
"reason": "docker system prune removes all unused data. Use targeted cleanup instead."
}
]
}
```
#### Multiple rules
```json
{
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", ".", "-u", "--update"],
"reason": "Use 'git add <specific-files>' instead of blanket add."
},
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Use npx or local install instead of global."
}
]
}
```
### Error Handling
Custom rules use **silent fallback** error handling. If your config file is invalid, the safety net silently falls back to built-in rules only:
| Scenario | Behavior |
|----------|----------|
| Config file not found | Silent — use built-in rules only |
| Empty config file | Silent — use built-in rules only |
| Invalid JSON syntax | Silent — use built-in rules only |
| Missing required field | Silent — use built-in rules only |
| Invalid field format | Silent — use built-in rules only |
| Duplicate rule name | Silent — use built-in rules only |
> [!IMPORTANT]
> If you add or modify custom rules manually, always validate them with `npx -y cc-safety-net --verify-config` or `/verify-custom-rules` slash command in your coding agent.
### Block Output Format
When a custom rule blocks a command, the output includes the rule name:
```text
BLOCKED by Safety Net
Reason: [block-git-add-all] Use 'git add <specific-files>' instead of blanket add.
Command: git add -A
```
## Advanced Features
### Strict Mode
By default, unparseable commands are allowed through. Enable strict mode to fail-closed
when the hook input or shell command cannot be safely analyzed (e.g., invalid JSON,
unterminated quotes, malformed `bash -c` wrappers):
```bash
export SAFETY_NET_STRICT=1
```
### Paranoid Mode
Paranoid mode enables stricter safety checks that may be disruptive to normal workflows.
You can enable it globally or via focused toggles:
```bash
# Enable all paranoid checks
export SAFETY_NET_PARANOID=1
# Or enable specific paranoid checks
export SAFETY_NET_PARANOID_RM=1
export SAFETY_NET_PARANOID_INTERPRETERS=1
```
Paranoid behavior:
- **rm**: blocks non-temp `rm -rf` even within the current working directory.
- **interpreters**: blocks interpreter one-liners like `python -c`, `node -e`, `ruby -e`,
and `perl -e` (these can hide destructive commands).
### Shell Wrapper Detection
The guard recursively analyzes commands wrapped in shells:
```bash
bash -c 'git reset --hard' # Blocked
sh -lc 'rm -rf /' # Blocked
```
### Interpreter One-Liner Detection
Detects destructive commands hidden in Python/Node/Ruby/Perl one-liners:
```bash
python -c 'import os; os.system("rm -rf /")' # Blocked
```
### Secret Redaction
Block messages automatically redact sensitive data (tokens, passwords, API keys) to prevent leaking secrets in logs.
### Audit Logging
All blocked commands are logged to `~/.cc-safety-net/logs/<session_id>.jsonl` for audit purposes:
```json
{"ts": "2025-01-15T10:30:00Z", "command": "git reset --hard", "segment": "git reset --hard", "reason": "...", "cwd": "/path/to/project"}
```
Sensitive data in log entries is automatically redacted.
## License
MIT

View File

@@ -0,0 +1,70 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"title": "Safety Net Configuration",
"description": "Configuration file for cc-safety-net plugin custom rules",
"type": "object",
"properties": {
"$schema": {
"description": "JSON Schema reference for IDE support",
"type": "string"
},
"version": {
"type": "number",
"const": 1,
"description": "Schema version (must be 1)"
},
"rules": {
"default": [],
"description": "Custom blocking rules",
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$",
"description": "Unique identifier for the rule (case-insensitive for duplicate detection)"
},
"command": {
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
"description": "Base command to match (e.g., 'git', 'npm', 'docker'). Paths are normalized to basename."
},
"subcommand": {
"description": "Optional subcommand to match (e.g., 'add', 'install'). If omitted, matches any subcommand.",
"type": "string",
"pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
},
"block_args": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Arguments that trigger the block. Command is blocked if ANY of these are present."
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Message shown when the command is blocked"
}
},
"required": [
"name",
"command",
"block_args",
"reason"
],
"additionalProperties": false,
"description": "A custom rule that blocks specific command patterns"
}
}
},
"required": [
"version"
],
"additionalProperties": false
}

View File

@@ -0,0 +1,14 @@
id: no-dynamic-import
snapshots:
await import('bar'):
labels:
- source: await import('bar')
style: primary
start: 0
end: 19
const foo = await import('bar'):
labels:
- source: await import('bar')
style: primary
start: 12
end: 31

View File

@@ -0,0 +1,7 @@
id: no-dynamic-import
valid:
- "import { foo } from 'bar'"
- "import * as foo from 'bar'"
invalid:
- "await import('bar')"
- "const foo = await import('bar')"

View File

@@ -0,0 +1,6 @@
id: no-dynamic-import
language: typescript
rule:
pattern: await import($PATH)
message: "Dynamic import() is not allowed. Use static imports at the top of the file instead."
severity: error

View File

@@ -0,0 +1,44 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"files": {
"includes": [
"src/**",
"tests/**",
"scripts/**",
"knip.ts",
"*.json",
"!node_modules",
"!dist",
"!coverage"
]
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noTemplateCurlyInString": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}

View File

@@ -0,0 +1,264 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "cc-safety-net",
"dependencies": {
"shell-quote": "^1.8.3",
},
"devDependencies": {
"@ast-grep/cli": "^0.40.4",
"@biomejs/biome": "2.3.10",
"@opencode-ai/plugin": "^1.0.224",
"@types/bun": "latest",
"@types/shell-quote": "^1.7.5",
"husky": "^9.1.7",
"knip": "^5.79.0",
"lint-staged": "^16.2.7",
"zod": "^4.3.5",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"trustedDependencies": [
"@ast-grep/cli",
],
"packages": {
"@ast-grep/cli": ["@ast-grep/cli@0.40.4", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.4", "@ast-grep/cli-darwin-x64": "0.40.4", "@ast-grep/cli-linux-arm64-gnu": "0.40.4", "@ast-grep/cli-linux-x64-gnu": "0.40.4", "@ast-grep/cli-win32-arm64-msvc": "0.40.4", "@ast-grep/cli-win32-ia32-msvc": "0.40.4", "@ast-grep/cli-win32-x64-msvc": "0.40.4" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-YK8Ow/kWUHEOXfyOh/OuQfBIgGJh1Gwq2rwVQ2brwhx3s8DJDtlJ9cUF60fH2TZr94iIXsnRSdY6QQ4XdylfDQ=="],
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P2/odqyCyhXXJoHYKTybmXZbG1vzqPgtiAC6SFAVMveXIp9GDz++vfJ8CBa3Xk93JaD97m/eRgk7DOkclSDtfg=="],
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-FRPfB0yGuZGGr/8Z212bnWq1+Q/KSRmgeeKwN80A4PKwE7QvG6CQqLNzyxl8l8zhyLWEseVb9blTAWJDzWq07g=="],
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-kMTOPf6uxz76qbBVv58gCpZka7tZGogyPlvAvnti3JbrrqqnEnZaYwa5hxxmdaIv4PJ3GbajZLyv0ZA017sjDg=="],
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EtasdfTF1U+gFAQFthqHlfP6aQN+BJcIecfcbxRsTHsFw171uWMZ1ox3p6iZQGFo1ZH1UFJhwNP9lgAx0EVkkA=="],
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-raoclzWXPkjzketq2L2SoQysnVT/cXU4o9uvOFACO1S37rXEU02FaJ3DRcOaTe5b4QDnKAbeu+AN5JGJmA7bkA=="],
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-nsOaHfASmq/aw1NNzHVxVp2Qh22RFTcBIxHYI7vjDBg++eGuNu6BQlNI4omAljzeZMDSgtbLjz5QDRw9UtZe9g=="],
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.4", "", { "os": "win32", "cpu": "x64" }, "sha512-7Fay4iNE3GvaPDtypedfXhSRfMgtfL/BKYeNVoW/JMTNmXDQHzbzQ36Y3FxVb+6u51MF/LdZwk9ofVZEquRYMA=="],
"@biomejs/biome": ["@biomejs/biome@2.3.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.10", "@biomejs/cli-darwin-x64": "2.3.10", "@biomejs/cli-linux-arm64": "2.3.10", "@biomejs/cli-linux-arm64-musl": "2.3.10", "@biomejs/cli-linux-x64": "2.3.10", "@biomejs/cli-linux-x64-musl": "2.3.10", "@biomejs/cli-win32-arm64": "2.3.10", "@biomejs/cli-win32-x64": "2.3.10" }, "bin": { "biome": "bin/biome" } }, "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="],
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.224", "", { "dependencies": { "@opencode-ai/sdk": "1.0.224", "zod": "4.1.8" } }, "sha512-V2Su55FI6NGyabFHo853+8r9h66q//gsYWCIODbwRs47qi4VfbFylfddJxQDD+/M/H7w0++ojbQC9YCLNDXdKw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.224", "", {}, "sha512-gODyWLDTaz38qISxRdJKsEiFqvJNcFzu4/awoSICIl8j8gx6qDxLsYWVp/ToO4LKXTvHMn8yyZpM3ZEdGhDC+g=="],
"@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.16.2", "", { "os": "android", "cpu": "arm" }, "sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ=="],
"@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.16.2", "", { "os": "android", "cpu": "arm64" }, "sha512-fEk+g/g2rJ6LnBVPqeLcx+/alWZ/Db1UlXG+ZVivip0NdrnOzRL48PAmnxTMGOrLwsH1UDJkwY3wOjrrQltCqg=="],
"@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.16.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pkbp1qi7kdUX6k3Fk1PvAg6p7ruwaWKg1AhOlDgrg2vLXjtv9ZHo7IAQN6kLj0W771dPJZWqNxoqTPacp2oYWA=="],
"@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.16.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-FYCGcU1iSoPkADGLfQbuj0HWzS+0ItjDCt9PKtu2Hzy6T0dxO4Y1enKeCOxCweOlmLEkSxUlW5UPT4wvT3LnAg=="],
"@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.16.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1zHCoK6fMcBjE54P2EG/z70rTjcRxvyKfvk4E/QVrWLxNahuGDFZIxoEoo4kGnnEcmPj41F0c2PkrQbqlpja5g=="],
"@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+ucLYz8EO5FDp6kZ4o1uDmhoP+M98ysqiUW4hI3NmfiOJQWLrAzQjqaTdPfIOzlCXBU9IHp5Cgxu6wPjVb8dbA=="],
"@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.16.2", "", { "os": "linux", "cpu": "arm" }, "sha512-qq+TpNXyw1odDgoONRpMLzH4hzhwnEw55398dL8rhKGvvYbio71WrJ00jE+hGlEi7H1Gkl11KoPJRaPlRAVGPw=="],
"@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-xlMh4gNtplNQEwuF5icm69udC7un0WyzT5ywOeHrPMEsghKnLjXok2wZgAA7ocTm9+JsI+nVXIQa5XO1x+HPQg=="],
"@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.16.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OZs33QTMi0xmHv/4P0+RAKXJTBk7UcMH5tpTaCytWRXls/DGaJ48jOHmriQGK2YwUqXl+oneuNyPOUO0obJ+Hg=="],
"@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.16.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UVyuhaV32dJGtF6fDofOcBstg9JwB2Jfnjfb8jGlu3xcG+TsubHRhuTwQ6JZ1sColNT1nMxBiu7zdKUEZi1kwg=="],
"@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.16.2", "", { "os": "linux", "cpu": "none" }, "sha512-YZZS0yv2q5nE1uL/Fk4Y7m9018DSEmDNSG8oJzy1TJjA1jx5HL52hEPxi98XhU6OYhSO/vC1jdkJeE8TIHugug=="],
"@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.16.2", "", { "os": "linux", "cpu": "none" }, "sha512-9VYuypwtx4kt1lUcwJAH4dPmgJySh4/KxtAPdRoX2BTaZxVm/yEXHq0mnl/8SEarjzMvXKbf7Cm6UBgptm3DZw=="],
"@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.16.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3gbwQ+xlL5gpyzgSDdC8B4qIM4mZaPDLaFOi3c/GV7CqIdVJc5EZXW4V3T6xwtPBOpXPXfqQLbhTnUD4SqwJtA=="],
"@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0WcK0j54tSwWa+hQaJMScZdWneqE7xixp/vpFqlkbhuKW9dRHykPAFvSYg1YJ3MJgu9ZzVNpYHhPKJiEQq57Q=="],
"@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.16.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjUm3w96P2t47nWywGwj1A2mAVBI/8IoS7XHhcogWCfXnEI3M6NPIRQPYAZW4s5/u3u6w1uPtgOwffj2XIOb/g=="],
"@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.16.2", "", { "os": "none", "cpu": "arm64" }, "sha512-OFVQ2x3VenTp13nIl6HcQ/7dmhFmM9dg2EjKfHcOtYfrVLQdNR6THFU7GkMdmc8DdY1zLUeilHwBIsyxv5hkwQ=="],
"@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.16.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-+O1sY3RrGyA2AqDnd3yaDCsqZqCblSTEpY7TbbaOaw0X7iIbGjjRLdrQk9StG3QSiZuBy9FdFwotIiSXtwvbAQ=="],
"@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.16.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-jMrMJL+fkx6xoSMFPOeyQ1ctTFjavWPOSZEKUY5PebDwQmC9cqEr4LhdTnGsOtFrWYLXlEU4xWeMdBoc/XKkOA=="],
"@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.16.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-tl0xDA5dcQplG2yg2ZhgVT578dhRFafaCfyqMEAXq8KNpor85nJ53C3PLpfxD2NKzPioFgWEexNsjqRi+kW2Mg=="],
"@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.16.2", "", { "os": "win32", "cpu": "x64" }, "sha512-M7z0xjYQq1HdJk2DxTSLMvRMyBSI4wn4FXGcVQBsbAihgXevAReqwMdb593nmCK/OiFwSNcOaGIzUvzyzQ+95w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="],
"ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"knip": ["knip@5.79.0", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.1", "minimist": "^1.2.8", "oxc-resolver": "^11.15.0", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-rcg+mNdqm6UiTuRVyy6UuuHw1n4ABMpNXDtrfGaCeUtJoRBAvAENIebr8YMtOz6XE7iVHZ8+rY7skgEtosczhQ=="],
"lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="],
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"oxc-resolver": ["oxc-resolver@11.16.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.16.2", "@oxc-resolver/binding-android-arm64": "11.16.2", "@oxc-resolver/binding-darwin-arm64": "11.16.2", "@oxc-resolver/binding-darwin-x64": "11.16.2", "@oxc-resolver/binding-freebsd-x64": "11.16.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.16.2", "@oxc-resolver/binding-linux-arm64-musl": "11.16.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.16.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-musl": "11.16.2", "@oxc-resolver/binding-openharmony-arm64": "11.16.2", "@oxc-resolver/binding-wasm32-wasi": "11.16.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.16.2", "@oxc-resolver/binding-win32-ia32-msvc": "11.16.2", "@oxc-resolver/binding-win32-x64-msvc": "11.16.2" } }, "sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"knip/zod": ["zod@4.3.4", "", {}, "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
}
}

View File

@@ -0,0 +1,3 @@
[test]
coverageThreshold = 0.9

View File

@@ -0,0 +1,8 @@
coverage:
status:
project:
default:
informational: true # bun test enforces 90% floor
patch:
default:
informational: true # bun test enforces 90% floor

View File

@@ -0,0 +1,132 @@
---
description: Set custom rules for Safety Net
allowed-tools: Bash, Read, Write, AskUserQuestion
---
You are helping the user configure custom blocking rules for claude-code-safety-net.
## Context
### Schema Documentation
!`npx -y cc-safety-net --custom-rules-doc`
## Your Task
Follow this flow exactly:
### Step 1: Ask for Scope
Use AskUserQuestion to let user select scope:
```json
{
"questions": [
{
"question": "Which scope would you like to configure?",
"header": "Configure",
"multiSelect": false,
"options": [
{
"label": "User",
"description": "(`~/.cc-safety-net/config.json`) - applies to all your projects"
},
{
"label": "Project",
"description": "(`.safety-net.json`) - applies only to this project"
}
]
}
]
}
```
### Step 2: Show Examples and Ask for Rules
Show examples in natural language:
- "Block `git add -A` and `git add .` to prevent blanket staging"
- "Block `npm install -g` to prevent global package installs"
- "Block `docker system prune` to prevent accidental cleanup"
Ask the user to describe rules in natural language. They can list multiple.
### Step 3: Generate and Show JSON Config
Parse user input and generate valid schema JSON using the schema documentation above.
Then show the generated config JSON to the user.
### Step 4: Ask for Confirmation
Use AskUserQuestion to let user choose:
```json
{
"questions": [
{
"question": "Does this look correct?",
"header": "Confirmation",
"multiSelect": false,
"options": [
{
"label": "Yes",
},
{
"label": "No",
}
]
}
]
}
```
### Step 5: Check and Handle Existing Config
1. Check existing User Config with `cat ~/.cc-safety-net/config.json 2>/dev/null || echo "No user config found"`
2. Check existing Project Config with `cat .safety-net.json 2>/dev/null || echo "No project config found"`
If the chosen scope already has a config:
Show the existing config to the user.
Use AskUserQuestion tool to let user choose:
```json
{
"questions": [
{
"question": "The chosen scope already has a config. What would you like to do?",
"header": "Configure",
"multiSelect": false,
"options": [
{
"label": "Merge",
},
{
"label": "Replace",
}
]
}
]
}
```
### Step 6: Write and Validate
Write the config to the chosen scope, then validate with `npx -y cc-safety-net --verify-config`.
If validation errors:
- Show specific errors
- Offer to fix with your best suggestion
- Confirm before proceeding
### Step 7: Confirm Success
Tell the user:
1. Config saved to [path]
2. **Changes take effect immediately** - no restart needed
3. Summary of rules added
## Important Notes
- Custom rules can only ADD restrictions, not bypass built-in protections
- Rule names must be unique (case-insensitive)
- Invalid config → entire config ignored, only built-in rules apply

View File

@@ -0,0 +1,172 @@
---
description: Set Safety Net status line in Claude Code settings
allowed-tools: Bash, Read, Write, AskUserQuestion
---
You are helping the user configure the Safety Net status line in their Claude Code settings.
## Context
### Schema Documentation
The `statusLine` field in `~/.claude/settings.json` has this structure:
```json
{
"statusLine": {
"type": "command",
"command": "<shell command to execute>",
"padding": <optional number>
}
}
```
- `type`: Must be `"command"`
- `command`: Shell command that outputs the status line text
- `padding`: Optional number for spacing
## Your Task
Follow this flow exactly:
### Step 1: Ask for Package Runner
Use AskUserQuestion to let user select their preferred package runner:
```json
{
"questions": [
{
"question": "Which package runner would you like to use?",
"header": "Runner",
"multiSelect": false,
"options": [
{
"label": "bunx (Recommended)",
"description": "Uses Bun's package runner - faster startup"
},
{
"label": "npx",
"description": "Uses npm's package runner - more widely available"
}
]
}
]
}
```
### Step 2: Check Existing Settings
Read the current settings file:
```bash
cat ~/.claude/settings.json 2>/dev/null || echo "{}"
```
Parse the JSON and check if `statusLine.command` already exists.
### Step 3: Handle Existing Command
If `statusLine.command` already exists:
1. Show the current command to the user
2. Use AskUserQuestion to let user choose:
```json
{
"questions": [
{
"question": "The statusLine command is already set. What would you like to do?",
"header": "Existing",
"multiSelect": false,
"options": [
{
"label": "Replace",
"description": "Replace the existing command with Safety Net statusline"
},
{
"label": "Pipe",
"description": "Add Safety Net at the end using pipe (existing_command | cc-safety-net --statusline)"
}
]
}
]
}
```
### Step 4: Generate the Configuration
Based on user choices:
**If Replace or no existing command:**
```json
{
"statusLine": {
"type": "command",
"command": "bunx cc-safety-net --statusline"
}
}
```
(Use `npx -y` instead of `bunx` if user selected npx)
**If Pipe:**
```json
{
"statusLine": {
"type": "command",
"command": "<existing_command> | bunx cc-safety-net --statusline"
}
}
```
### Step 5: Show and Confirm
Show the generated config to the user.
Use AskUserQuestion to confirm:
```json
{
"questions": [
{
"question": "Does this configuration look correct?",
"header": "Confirm",
"multiSelect": false,
"options": [
{
"label": "Yes, apply it",
"description": "Write the configuration to ~/.claude/settings.json"
},
{
"label": "No, cancel",
"description": "Cancel without making changes"
}
]
}
]
}
```
### Step 6: Write Configuration
If user confirms:
1. Read existing `~/.claude/settings.json` (or start with `{}` if it doesn't exist)
2. Merge the new `statusLine` configuration
3. Write back to `~/.claude/settings.json` with proper JSON formatting (2-space indent)
Use the Write tool to update the file.
### Step 7: Confirm Success
Tell the user:
1. Configuration saved to `~/.claude/settings.json`
2. **Changes take effect immediately** - no restart needed
3. Summary of what was configured
## Important Notes
- The settings file is located at `~/.claude/settings.json`
- If the file doesn't exist, create it with the statusLine configuration
- Preserve all existing settings when merging
- Use `npx -y` (not just `npx`) to skip prompts when using npm

View File

@@ -0,0 +1,16 @@
---
description: Verify custom rules for Safety Net
allowed-tools: Bash, Read, Write, AskUserQuestion
---
You are helping the user verify the custom rules config file.
## Your Task
Run `npx -y cc-safety-net --verify-config` to check current validation status
If the config has validation errors:
1. Show the specific validation errors
2. Run `npx -y cc-safety-net --custom-rules-doc` to read the schema documentation
3. Use AskUserQuestion tool to offer fixes with your best suggestions
4. After fixing, run `npx -y cc-safety-net --verify-config` to verify again

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export declare function runClaudeCodeHook(): Promise<void>;

View File

@@ -0,0 +1 @@
export declare const CUSTOM_RULES_DOC = "# Custom Rules Reference\n\nAgent reference for generating `.safety-net.json` config files.\n\n## Config Locations\n\n| Scope | Path | Priority |\n|-------|------|----------|\n| User | `~/.cc-safety-net/config.json` | Lower |\n| Project | `.safety-net.json` (cwd) | Higher (overrides user) |\n\nDuplicate rule names (case-insensitive) \u2192 project wins.\n\n## Schema\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [...]\n}\n```\n\n- `$schema`: Optional. Enables IDE autocomplete and inline validation.\n- `version`: Required. Must be `1`.\n- `rules`: Optional. Defaults to `[]`.\n\n**Always include `$schema`** when generating config files for IDE support.\n\n## Rule Fields\n\n| Field | Required | Constraints |\n|-------|----------|-------------|\n| `name` | Yes | `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` \u2014 unique (case-insensitive) |\n| `command` | Yes | `^[a-zA-Z][a-zA-Z0-9_-]*$` \u2014 basename only, not path |\n| `subcommand` | No | Same pattern as command. Omit to match any. |\n| `block_args` | Yes | Non-empty array of non-empty strings |\n| `reason` | Yes | Non-empty string, max 256 chars |\n\n## Guidelines:\n\n- `name`: kebab-case, descriptive (e.g., `block-git-add-all`)\n- `command`: binary name only, lowercase\n- `subcommand`: omit if rule applies to any subcommand\n- `block_args`: include all variants (e.g., both `-g` and `--global`)\n- `reason`: explain why blocked AND suggest alternative\n\n## Matching Behavior\n\n- **Command**: Normalized to basename (`/usr/bin/git` \u2192 `git`)\n- **Subcommand**: First non-option argument after command\n- **Arguments**: Matched literally. Command blocked if **any** `block_args` item present.\n- **Short options**: Expanded (`-Ap` matches `-A`)\n- **Long options**: Exact match (`--all-files` does NOT match `--all`)\n- **Execution order**: Built-in rules first, then custom rules (additive only)\n\n## Examples\n\n### Block `git add -A`\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-git-add-all\",\n \"command\": \"git\",\n \"subcommand\": \"add\",\n \"block_args\": [\"-A\", \"--all\", \".\"],\n \"reason\": \"Use 'git add <specific-files>' instead.\"\n }\n ]\n}\n```\n\n### Block global npm install\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-npm-global\",\n \"command\": \"npm\",\n \"subcommand\": \"install\",\n \"block_args\": [\"-g\", \"--global\"],\n \"reason\": \"Use npx or local install.\"\n }\n ]\n}\n```\n\n### Block docker system prune\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-docker-prune\",\n \"command\": \"docker\",\n \"subcommand\": \"system\",\n \"block_args\": [\"prune\"],\n \"reason\": \"Use targeted cleanup instead.\"\n }\n ]\n}\n```\n\n## Error Handling\n\nInvalid config \u2192 silent fallback to built-in rules only. No custom rules applied.\n";

View File

@@ -0,0 +1 @@
export declare function runGeminiCLIHook(): Promise<void>;

View File

@@ -0,0 +1,2 @@
export declare function printHelp(): void;
export declare function printVersion(): void;

View File

@@ -0,0 +1 @@
export declare function printStatusline(): Promise<void>;

View File

@@ -0,0 +1,12 @@
/**
* Verify user and project scope config files for safety-net.
*/
export interface VerifyConfigOptions {
userConfigPath?: string;
projectConfigPath?: string;
}
/**
* Verify config files and print results.
* @returns Exit code (0 = success, 1 = errors found)
*/
export declare function verifyConfig(options?: VerifyConfigOptions): number;

View File

@@ -0,0 +1,21 @@
import type { AnalyzeOptions, AnalyzeResult } from '../types.ts';
import { findHasDelete } from './analyze/find.ts';
import { extractParallelChildCommand } from './analyze/parallel.ts';
import { hasRecursiveForceFlags } from './analyze/rm-flags.ts';
import { segmentChangesCwd } from './analyze/segment.ts';
import { extractXargsChildCommand, extractXargsChildCommandWithInfo } from './analyze/xargs.ts';
import { loadConfig } from './config.ts';
export declare function analyzeCommand(command: string, options?: AnalyzeOptions): AnalyzeResult | null;
export { loadConfig };
/** @internal Exported for testing */
export { findHasDelete as _findHasDelete };
/** @internal Exported for testing */
export { extractParallelChildCommand as _extractParallelChildCommand };
/** @internal Exported for testing */
export { hasRecursiveForceFlags as _hasRecursiveForceFlags };
/** @internal Exported for testing */
export { segmentChangesCwd as _segmentChangesCwd };
/** @internal Exported for testing */
export { extractXargsChildCommand as _extractXargsChildCommand };
/** @internal Exported for testing */
export { extractXargsChildCommandWithInfo as _extractXargsChildCommandWithInfo };

View File

@@ -0,0 +1,5 @@
import { type AnalyzeOptions, type AnalyzeResult, type Config } from '../../types.ts';
export type InternalOptions = AnalyzeOptions & {
config: Config;
};
export declare function analyzeCommandInternal(command: string, depth: number, options: InternalOptions): AnalyzeResult | null;

View File

@@ -0,0 +1 @@
export declare const DISPLAY_COMMANDS: ReadonlySet<string>;

View File

@@ -0,0 +1 @@
export declare function dangerousInText(text: string): string | null;

View File

@@ -0,0 +1,6 @@
export declare function analyzeFind(tokens: readonly string[]): string | null;
/**
* Check if find command has -delete action (not as argument to another option).
* Handles cases like "find -name -delete" where -delete is a filename pattern.
*/
export declare function findHasDelete(tokens: readonly string[]): boolean;

View File

@@ -0,0 +1,2 @@
export declare function extractInterpreterCodeArg(tokens: readonly string[]): string | null;
export declare function containsDangerousCode(code: string): boolean;

View File

@@ -0,0 +1,9 @@
export interface ParallelAnalyzeContext {
cwd: string | undefined;
originalCwd: string | undefined;
paranoidRm: boolean | undefined;
allowTmpdirVar: boolean;
analyzeNested: (command: string) => string | null;
}
export declare function analyzeParallel(tokens: readonly string[], context: ParallelAnalyzeContext): string | null;
export declare function extractParallelChildCommand(tokens: readonly string[]): string[];

View File

@@ -0,0 +1 @@
export declare function hasRecursiveForceFlags(tokens: readonly string[]): boolean;

View File

@@ -0,0 +1,8 @@
import { type AnalyzeOptions, type Config } from '../../types.ts';
export type InternalOptions = AnalyzeOptions & {
config: Config;
effectiveCwd: string | null | undefined;
analyzeNested: (command: string) => string | null;
};
export declare function analyzeSegment(tokens: string[], depth: number, options: InternalOptions): string | null;
export declare function segmentChangesCwd(segment: readonly string[]): boolean;

View File

@@ -0,0 +1 @@
export declare function extractDashCArg(tokens: readonly string[]): string | null;

View File

@@ -0,0 +1 @@
export declare function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>): boolean;

View File

@@ -0,0 +1,14 @@
export interface XargsAnalyzeContext {
cwd: string | undefined;
originalCwd: string | undefined;
paranoidRm: boolean | undefined;
allowTmpdirVar: boolean;
}
export declare function analyzeXargs(tokens: readonly string[], context: XargsAnalyzeContext): string | null;
interface XargsParseResult {
childTokens: string[];
replacementToken: string | null;
}
export declare function extractXargsChildCommandWithInfo(tokens: readonly string[]): XargsParseResult;
export declare function extractXargsChildCommand(tokens: readonly string[]): string[];
export {};

View File

@@ -0,0 +1,17 @@
/**
* Sanitize session ID to prevent path traversal attacks.
* Returns null if the session ID is invalid.
* @internal Exported for testing
*/
export declare function sanitizeSessionIdForFilename(sessionId: string): string | null;
/**
* Write an audit log entry for a denied command.
* Logs are written to ~/.cc-safety-net/logs/<session_id>.jsonl
*/
export declare function writeAuditLog(sessionId: string, command: string, segment: string, reason: string, cwd: string | null, options?: {
homeDir?: string;
}): void;
/**
* Redact secrets from text to avoid leaking sensitive information in logs.
*/
export declare function redactSecrets(text: string): string;

View File

@@ -0,0 +1,12 @@
import { type Config, type ValidationResult } from '../types.ts';
export interface LoadConfigOptions {
/** Override user config directory (for testing) */
userConfigDir?: string;
}
export declare function loadConfig(cwd?: string, options?: LoadConfigOptions): Config;
/** @internal Exported for testing */
export declare function validateConfig(config: unknown): ValidationResult;
export declare function validateConfigFile(path: string): ValidationResult;
export declare function getUserConfigPath(): string;
export declare function getProjectConfigPath(cwd?: string): string;
export type { ValidationResult };

View File

@@ -0,0 +1 @@
export declare const CUSTOM_RULES_DOC = "# Custom Rules Reference\n\nAgent reference for generating `.safety-net.json` config files.\n\n## Config Locations\n\n| Scope | Path | Priority |\n|-------|------|----------|\n| User | `~/.cc-safety-net/config.json` | Lower |\n| Project | `.safety-net.json` (cwd) | Higher (overrides user) |\n\nDuplicate rule names (case-insensitive) \u2192 project wins.\n\n## Schema\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [...]\n}\n```\n\n- `$schema`: Optional. Enables IDE autocomplete and inline validation.\n- `version`: Required. Must be `1`.\n- `rules`: Optional. Defaults to `[]`.\n\n**Always include `$schema`** when generating config files for IDE support.\n\n## Rule Fields\n\n| Field | Required | Constraints |\n|-------|----------|-------------|\n| `name` | Yes | `^[a-zA-Z][a-zA-Z0-9_-]{0,63}$` \u2014 unique (case-insensitive) |\n| `command` | Yes | `^[a-zA-Z][a-zA-Z0-9_-]*$` \u2014 basename only, not path |\n| `subcommand` | No | Same pattern as command. Omit to match any. |\n| `block_args` | Yes | Non-empty array of non-empty strings |\n| `reason` | Yes | Non-empty string, max 256 chars |\n\n## Guidelines:\n\n- `name`: kebab-case, descriptive (e.g., `block-git-add-all`)\n- `command`: binary name only, lowercase\n- `subcommand`: omit if rule applies to any subcommand\n- `block_args`: include all variants (e.g., both `-g` and `--global`)\n- `reason`: explain why blocked AND suggest alternative\n\n## Matching Behavior\n\n- **Command**: Normalized to basename (`/usr/bin/git` \u2192 `git`)\n- **Subcommand**: First non-option argument after command\n- **Arguments**: Matched literally. Command blocked if **any** `block_args` item present.\n- **Short options**: Expanded (`-Ap` matches `-A`)\n- **Long options**: Exact match (`--all-files` does NOT match `--all`)\n- **Execution order**: Built-in rules first, then custom rules (additive only)\n\n## Examples\n\n### Block `git add -A`\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-git-add-all\",\n \"command\": \"git\",\n \"subcommand\": \"add\",\n \"block_args\": [\"-A\", \"--all\", \".\"],\n \"reason\": \"Use 'git add <specific-files>' instead.\"\n }\n ]\n}\n```\n\n### Block global npm install\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-npm-global\",\n \"command\": \"npm\",\n \"subcommand\": \"install\",\n \"block_args\": [\"-g\", \"--global\"],\n \"reason\": \"Use npx or local install.\"\n }\n ]\n}\n```\n\n### Block docker system prune\n\n```json\n{\n \"$schema\": \"https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json\",\n \"version\": 1,\n \"rules\": [\n {\n \"name\": \"block-docker-prune\",\n \"command\": \"docker\",\n \"subcommand\": \"system\",\n \"block_args\": [\"prune\"],\n \"reason\": \"Use targeted cleanup instead.\"\n }\n ]\n}\n```\n\n## Error Handling\n\nInvalid config \u2192 silent fallback to built-in rules only. No custom rules applied.\n";

View File

@@ -0,0 +1 @@
export declare function envTruthy(name: string): boolean;

View File

@@ -0,0 +1,10 @@
type RedactFn = (text: string) => string;
export interface FormatBlockedMessageInput {
reason: string;
command?: string;
segment?: string;
maxLen?: number;
redact?: RedactFn;
}
export declare function formatBlockedMessage(input: FormatBlockedMessageInput): string;
export {};

View File

@@ -0,0 +1,2 @@
import type { CustomRule } from '../types.ts';
export declare function checkCustomRules(tokens: string[], rules: CustomRule[]): string | null;

View File

@@ -0,0 +1,8 @@
export declare function analyzeGit(tokens: readonly string[]): string | null;
declare function extractGitSubcommandAndRest(tokens: readonly string[]): {
subcommand: string | null;
rest: string[];
};
declare function getCheckoutPositionalArgs(tokens: readonly string[]): string[];
/** @internal Exported for testing */
export { extractGitSubcommandAndRest as _extractGitSubcommandAndRest, getCheckoutPositionalArgs as _getCheckoutPositionalArgs, };

View File

@@ -0,0 +1,9 @@
export interface AnalyzeRmOptions {
cwd?: string;
originalCwd?: string;
paranoid?: boolean;
allowTmpdirVar?: boolean;
tmpdirOverridden?: boolean;
}
export declare function analyzeRm(tokens: string[], options?: AnalyzeRmOptions): string | null;
export declare function isHomeDirectory(cwd: string): boolean;

View File

@@ -0,0 +1,15 @@
export declare function splitShellCommands(command: string): string[][];
export interface EnvStrippingResult {
tokens: string[];
envAssignments: Map<string, string>;
}
export declare function stripEnvAssignmentsWithInfo(tokens: string[]): EnvStrippingResult;
export interface WrapperStrippingResult {
tokens: string[];
envAssignments: Map<string, string>;
}
export declare function stripWrappers(tokens: string[]): string[];
export declare function stripWrappersWithInfo(tokens: string[]): WrapperStrippingResult;
export declare function extractShortOpts(tokens: string[]): Set<string>;
export declare function normalizeCommandToken(token: string): string;
export declare function getBasename(token: string): string;

View File

@@ -0,0 +1,12 @@
/**
* Verify user and project scope config files for safety-net.
*/
export interface VerifyConfigOptions {
userConfigPath?: string;
projectConfigPath?: string;
}
/**
* Verify config files and print results.
* @returns Exit code (0 = success, 1 = errors found)
*/
export declare function verifyConfig(options?: VerifyConfigOptions): number;

View File

@@ -0,0 +1,2 @@
import type { BuiltinCommandName, BuiltinCommands } from './types.ts';
export declare function loadBuiltinCommands(disabledCommands?: BuiltinCommandName[]): BuiltinCommands;

View File

@@ -0,0 +1,2 @@
export * from './commands.ts';
export * from './types.ts';

View File

@@ -0,0 +1 @@
export declare const SET_CUSTOM_RULES_TEMPLATE = "You are helping the user configure custom blocking rules for claude-code-safety-net.\n\n## Context\n\n### Schema Documentation\n\n!`npx -y cc-safety-net --custom-rules-doc`\n\n## Your Task\n\nFollow this flow exactly:\n\n### Step 1: Ask for Scope\n\nAsk: **Which scope would you like to configure?**\n- **User** (`~/.cc-safety-net/config.json`) - applies to all your projects\n- **Project** (`.safety-net.json`) - applies only to this project\n\n### Step 2: Show Examples and Ask for Rules\n\nShow examples in natural language:\n- \"Block `git add -A` and `git add .` to prevent blanket staging\"\n- \"Block `npm install -g` to prevent global package installs\"\n- \"Block `docker system prune` to prevent accidental cleanup\"\n\nAsk the user to describe rules in natural language. They can list multiple.\n\n### Step 3: Generate JSON Config\n\nParse user input and generate valid schema JSON using the schema documentation above.\n\n### Step 4: Show Config and Confirm\n\nDisplay the generated JSON and ask:\n- \"Does this look correct?\"\n- \"Would you like to modify anything?\"\n\n### Step 5: Check and Handle Existing Config\n\n1. Check existing User Config with `cat ~/.cc-safety-net/config.json 2>/dev/null || echo \"No user config found\"`\n2. Check existing Project Config with `cat .safety-net.json 2>/dev/null || echo \"No project config found\"`\n\nIf the chosen scope already has a config:\nShow the existing config to the user.\nAsk: **Merge** (add new rules, duplicates use new version) or **Replace**?\n\n### Step 6: Write and Validate\n\nWrite the config to the chosen scope, then validate with `npx -y cc-safety-net --verify-config`.\n\nIf validation errors:\n- Show specific errors\n- Offer to fix with your best suggestion\n- Confirm before proceeding\n\n### Step 7: Confirm Success\n\nTell the user:\n1. Config saved to [path]\n2. **Changes take effect immediately** - no restart needed\n3. Summary of rules added\n\n## Important Notes\n\n- Custom rules can only ADD restrictions, not bypass built-in protections\n- Rule names must be unique (case-insensitive)\n- Invalid config \u2192 entire config ignored, only built-in rules apply";

View File

@@ -0,0 +1 @@
export declare const VERIFY_CUSTOM_RULES_TEMPLATE = "You are helping the user verify the custom rules config file.\n\n## Your Task\n\nRun `npx -y cc-safety-net --verify-config` to check current validation status\n\nIf the config has validation errors:\n1. Show the specific validation errors\n2. Run `npx -y cc-safety-net --custom-rules-doc` to read the schema documentation\n3. Offer to fix them with your best suggestion\n4. Ask for confirmation before proceeding\n5. After fixing, run `npx -y cc-safety-net --verify-config` to verify again";

View File

@@ -0,0 +1,6 @@
export type BuiltinCommandName = 'set-custom-rules' | 'verify-custom-rules';
export interface CommandDefinition {
description?: string;
template: string;
}
export type BuiltinCommands = Record<string, CommandDefinition>;

View File

@@ -0,0 +1,2 @@
import type { Plugin } from '@opencode-ai/plugin';
export declare const SafetyNetPlugin: Plugin;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
/**
* Shared types for the safety-net plugin.
*/
/** Custom rule definition from .safety-net.json */
export interface CustomRule {
/** Unique identifier for the rule */
name: string;
/** Base command to match (e.g., "git", "npm") */
command: string;
/** Optional subcommand to match (e.g., "add", "install") */
subcommand?: string;
/** Arguments that trigger the block */
block_args: string[];
/** Message shown when blocked */
reason: string;
}
/** Configuration loaded from .safety-net.json */
export interface Config {
/** Schema version (must be 1) */
version: number;
/** Custom blocking rules */
rules: CustomRule[];
}
/** Result of config validation */
export interface ValidationResult {
/** List of validation error messages */
errors: string[];
/** Set of rule names found (for duplicate detection) */
ruleNames: Set<string>;
}
/** Result of command analysis */
export interface AnalyzeResult {
/** The reason the command was blocked */
reason: string;
/** The specific segment that triggered the block */
segment: string;
}
/** Claude Code hook input format */
export interface HookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
permission_mode?: string;
hook_event_name: string;
tool_name: string;
tool_input: {
command: string;
description?: string;
};
tool_use_id?: string;
}
/** Claude Code hook output format */
export interface HookOutput {
hookSpecificOutput: {
hookEventName: string;
permissionDecision: 'allow' | 'deny';
permissionDecisionReason?: string;
};
}
/** Gemini CLI hook input format */
export interface GeminiHookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name: string;
timestamp?: string;
tool_name?: string;
tool_input?: {
command?: string;
[key: string]: unknown;
};
}
/** Gemini CLI hook output format */
export interface GeminiHookOutput {
decision: 'deny';
reason: string;
systemMessage: string;
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
}
/** Options for command analysis */
export interface AnalyzeOptions {
/** Current working directory */
cwd?: string;
/** Effective cwd after cd commands (null = unknown, undefined = use cwd) */
effectiveCwd?: string | null;
/** Loaded configuration */
config?: Config;
/** Fail-closed on unparseable commands */
strict?: boolean;
/** Block non-temp rm -rf even within cwd */
paranoidRm?: boolean;
/** Block interpreter one-liners */
paranoidInterpreters?: boolean;
/** Allow $TMPDIR paths (false when TMPDIR is overridden to non-temp) */
allowTmpdirVar?: boolean;
}
/** Audit log entry */
export interface AuditLogEntry {
ts: string;
command: string;
segment: string;
reason: string;
cwd?: string | null;
}
/** Constants */
export declare const MAX_RECURSION_DEPTH = 10;
export declare const MAX_STRIP_ITERATIONS = 20;
export declare const NAME_PATTERN: RegExp;
export declare const COMMAND_PATTERN: RegExp;
export declare const MAX_REASON_LENGTH = 256;
/** Shell operators that split commands */
export declare const SHELL_OPERATORS: Set<string>;
/** Shell wrappers that need recursive analysis */
export declare const SHELL_WRAPPERS: Set<string>;
/** Interpreters that can execute code */
export declare const INTERPRETERS: Set<string>;
/** Dangerous commands to detect in interpreter code */
export declare const DANGEROUS_PATTERNS: RegExp[];
export declare const PARANOID_INTERPRETERS_SUFFIX = "\n\n(Paranoid mode: interpreter one-liners are blocked.)";

View File

@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/dist/bin/cc-safety-net.js --claude-code"
}
]
}
]
}
}

View File

@@ -0,0 +1,8 @@
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
entry: ['src/index.ts', 'src/bin/cc-safety-net.ts', 'scripts/**/*.ts'],
project: ['src/**/*.ts!', 'scripts/**/*.ts!'],
};
export default config;

View File

@@ -0,0 +1,72 @@
{
"name": "cc-safety-net",
"version": "0.6.0",
"description": "Claude Code / OpenCode plugin - block destructive git and filesystem commands before execution",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"bin": {
"cc-safety-net": "dist/bin/cc-safety-net.js"
},
"files": [
"dist"
],
"scripts": {
"build": "bun run scripts/build.ts",
"build:types": "tsc --emitDeclarationOnly --declaration --noEmit false",
"build:schema": "bun run scripts/build-schema.ts",
"clean": "rm -rf dist",
"check": "bun run lint && bun run typecheck && bun run knip && bun run sg:scan && AGENT=1 bun test --coverage",
"lint": "biome check --write",
"typecheck": "tsc --project tsconfig.typecheck.json",
"knip": "knip --production",
"sg:scan": "ast-grep scan",
"test": "bun test",
"publish:dry-run": "bun run scripts/publish.ts --dry-run",
"prepare": "husky && bun run setup-hooks",
"setup-hooks": "bun -e 'await Bun.write(\".husky/pre-commit\", \"#!/usr/bin/env sh\\n\\nbun run knip && bun run lint-staged\\n\")' && chmod +x .husky/pre-commit"
},
"author": {
"name": "J Liew",
"email": "jliew@420024lab.com"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/kenryu42/claude-code-safety-net.git"
},
"bugs": {
"url": "https://github.com/kenryu42/claude-code-safety-net/issues"
},
"homepage": "https://github.com/kenryu42/claude-code-safety-net#readme",
"devDependencies": {
"@ast-grep/cli": "^0.40.4",
"@biomejs/biome": "2.3.10",
"@opencode-ai/plugin": "^1.0.224",
"@types/bun": "latest",
"@types/shell-quote": "^1.7.5",
"husky": "^9.1.7",
"knip": "^5.79.0",
"lint-staged": "^16.2.7",
"zod": "^4.3.5"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"shell-quote": "^1.8.3"
},
"trustedDependencies": [
"@ast-grep/cli"
],
"engines": {
"node": ">=18"
},
"keywords": [
"claude-code",
"opencode",
"safety",
"plugin",
"security"
]
}

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bun
import * as z from 'zod';
const SCHEMA_OUTPUT_PATH = 'assets/cc-safety-net.schema.json';
const CustomRuleSchema = z
.strictObject({
name: z
.string()
.regex(/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/)
.describe('Unique identifier for the rule (case-insensitive for duplicate detection)'),
command: z
.string()
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
.describe(
"Base command to match (e.g., 'git', 'npm', 'docker'). Paths are normalized to basename.",
),
subcommand: z
.string()
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/)
.optional()
.describe(
"Optional subcommand to match (e.g., 'add', 'install'). If omitted, matches any subcommand.",
),
block_args: z
.array(z.string().min(1))
.min(1)
.describe(
'Arguments that trigger the block. Command is blocked if ANY of these are present.',
),
reason: z.string().min(1).max(256).describe('Message shown when the command is blocked'),
})
.describe('A custom rule that blocks specific command patterns');
const ConfigSchema = z.strictObject({
$schema: z.string().optional().describe('JSON Schema reference for IDE support'),
version: z.literal(1).describe('Schema version (must be 1)'),
rules: z.array(CustomRuleSchema).default([]).describe('Custom blocking rules'),
});
async function main(): Promise<void> {
console.log('Generating JSON Schema...');
const jsonSchema = z.toJSONSchema(ConfigSchema, {
io: 'input',
target: 'draft-7',
});
const finalSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json',
title: 'Safety Net Configuration',
description: 'Configuration file for cc-safety-net plugin custom rules',
...jsonSchema,
};
await Bun.write(SCHEMA_OUTPUT_PATH, `${JSON.stringify(finalSchema, null, 2)}\n`);
console.log(`✓ JSON Schema generated: ${SCHEMA_OUTPUT_PATH}`);
}
main();

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bun
/**
* Build script that injects __PKG_VERSION__ at compile time
* to avoid embedding the full package.json in the bundle.
*/
import pkg from '../package.json';
const result = await Bun.build({
entrypoints: ['src/index.ts', 'src/bin/cc-safety-net.ts'],
outdir: 'dist',
target: 'node',
define: {
__PKG_VERSION__: JSON.stringify(pkg.version),
},
});
if (!result.success) {
console.error('Build failed:');
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
const indexOutput = result.outputs.find((o) => o.path.endsWith('index.js'));
const binOutput = result.outputs.find((o) => o.path.endsWith('cc-safety-net.js'));
if (indexOutput) {
console.log(` dist/index.js ${(indexOutput.size / 1024).toFixed(2)} KB`);
}
if (binOutput) {
console.log(` dist/bin/cc-safety-net.js ${(binOutput.size / 1024).toFixed(2)} KB`);
}
// Run build:types and build:schema
const typesResult = Bun.spawnSync(['bun', 'run', 'build:types']);
if (typesResult.exitCode !== 0) {
console.error('build:types failed');
process.exit(1);
}
const schemaResult = Bun.spawnSync(['bun', 'run', 'build:schema']);
if (schemaResult.exitCode !== 0) {
console.error('build:schema failed');
process.exit(1);
}

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env bun
import { $ } from 'bun';
export type CommandRunner = (
strings: TemplateStringsArray,
...values: readonly string[]
) => { text: () => Promise<string> };
const DEFAULT_RUNNER: CommandRunner = $;
export const EXCLUDED_AUTHORS = ['actions-user', 'github-actions[bot]', 'kenryu42'];
/** Regex to match included commit types (with optional scope) */
export const INCLUDED_COMMIT_PATTERN = /^(feat|fix)(\([^)]+\))?:/i;
export const REPO = process.env.GITHUB_REPOSITORY ?? 'kenryu42/claude-code-safety-net';
/** Paths that indicate Claude Code plugin changes */
const CLAUDE_CODE_PATHS = ['commands/', 'hooks/', '.claude-plugin/'];
/** Paths that indicate OpenCode plugin changes */
const OPENCODE_PATHS = ['.opencode/'];
/**
* Get the files changed in a commit.
*/
async function getChangedFiles(
hash: string,
runner: CommandRunner = DEFAULT_RUNNER,
): Promise<string[]> {
try {
const output = await runner`git diff-tree --no-commit-id --name-only -r ${hash}`.text();
return output.split('\n').filter(Boolean);
} catch {
return [];
}
}
/**
* Check if a file path belongs to Claude Code plugin.
*/
function isClaudeCodeFile(path: string): boolean {
return CLAUDE_CODE_PATHS.some((prefix) => path.startsWith(prefix));
}
/**
* Check if a file path belongs to OpenCode plugin.
*/
function isOpenCodeFile(path: string): boolean {
return OPENCODE_PATHS.some((prefix) => path.startsWith(prefix));
}
/**
* Classify a commit based on its changed files.
* Priority: core > claude-code > opencode (higher priority wins ties).
*/
function classifyCommit(files: string[]): 'core' | 'claude-code' | 'opencode' {
if (files.length === 0) return 'core';
const hasCore = files.some((file) => !isClaudeCodeFile(file) && !isOpenCodeFile(file));
if (hasCore) return 'core';
const hasClaudeCode = files.some((file) => isClaudeCodeFile(file));
if (hasClaudeCode) return 'claude-code';
return 'opencode';
}
/**
* Check if a commit message should be included in the changelog.
* @param message - The commit message (can include hash prefix like "abc1234 feat: message")
*/
export function isIncludedCommit(message: string): boolean {
// Remove optional hash prefix (e.g., "abc1234 " from git log output)
const messageWithoutHash = message.replace(/^\w+\s+/, '');
return INCLUDED_COMMIT_PATTERN.test(messageWithoutHash);
}
export async function getLatestReleasedTag(
runner: CommandRunner = DEFAULT_RUNNER,
): Promise<string | null> {
try {
const tag =
await runner`gh release list --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty'`.text();
return tag.trim() || null;
} catch {
return null;
}
}
interface CategorizedChangelog {
core: string[];
claudeCode: string[];
openCode: string[];
}
/**
* Format changelog and contributors into release notes.
*/
export function formatReleaseNotes(
changelog: CategorizedChangelog,
contributors: string[],
): string[] {
const notes: string[] = [];
// Core section
notes.push('## Core');
if (changelog.core.length > 0) {
notes.push(...changelog.core);
} else {
notes.push('No changes in this release');
}
// Claude Code section
notes.push('');
notes.push('## Claude Code');
if (changelog.claudeCode.length > 0) {
notes.push(...changelog.claudeCode);
} else {
notes.push('No changes in this release');
}
// OpenCode section
notes.push('');
notes.push('## OpenCode');
if (changelog.openCode.length > 0) {
notes.push(...changelog.openCode);
} else {
notes.push('No changes in this release');
}
// Contributors section
if (contributors.length > 0) {
notes.push(...contributors);
}
return notes;
}
export async function generateChangelog(
previousTag: string,
runner: CommandRunner = DEFAULT_RUNNER,
): Promise<CategorizedChangelog> {
const result: CategorizedChangelog = {
core: [],
claudeCode: [],
openCode: [],
};
try {
const log = await runner`git log ${previousTag}..HEAD --oneline --format="%h %s"`.text();
const commits = log.split('\n').filter((line) => line && isIncludedCommit(line));
for (const commit of commits) {
const hash = commit.split(' ')[0];
if (!hash) continue;
const files = await getChangedFiles(hash, runner);
const category = classifyCommit(files);
if (category === 'core') {
result.core.push(`- ${commit}`);
} else if (category === 'claude-code') {
result.claudeCode.push(`- ${commit}`);
} else {
result.openCode.push(`- ${commit}`);
}
}
} catch {
// No commits found
}
return result;
}
export async function getContributors(
previousTag: string,
runner: CommandRunner = DEFAULT_RUNNER,
): Promise<string[]> {
return getContributorsForRepo(previousTag, REPO, runner);
}
export async function getContributorsForRepo(
previousTag: string,
repo: string,
runner: CommandRunner = DEFAULT_RUNNER,
): Promise<string[]> {
const notes: string[] = [];
try {
const compare =
await runner`gh api "/repos/${repo}/compare/${previousTag}...HEAD" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text();
const contributors = new Map<string, string[]>();
for (const line of compare.split('\n').filter(Boolean)) {
const { login, message } = JSON.parse(line) as {
login: string | null;
message: string;
};
const title = message.split('\n')[0] ?? '';
if (!isIncludedCommit(title)) continue;
if (login && !EXCLUDED_AUTHORS.includes(login)) {
if (!contributors.has(login)) contributors.set(login, []);
contributors.get(login)?.push(title);
}
}
if (contributors.size > 0) {
notes.push('');
notes.push(
`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? 's' : ''}:**`,
);
for (const [username, userCommits] of contributors) {
notes.push(`- @${username}:`);
for (const commit of userCommits) {
notes.push(` - ${commit}`);
}
}
}
} catch {
// Failed to fetch contributors
}
return notes;
}
export type RunChangelogOptions = {
runner?: CommandRunner;
log?: (message: string) => void;
};
export async function runChangelog(options: RunChangelogOptions = {}): Promise<void> {
const runner = options.runner ?? DEFAULT_RUNNER;
const log = options.log ?? console.log;
const previousTag = await getLatestReleasedTag(runner);
if (!previousTag) {
log('Initial release');
return;
}
const changelog = await generateChangelog(previousTag, runner);
const contributors = await getContributorsForRepo(previousTag, REPO, runner);
const notes = formatReleaseNotes(changelog, contributors);
log(notes.join('\n'));
}
if (import.meta.main) {
runChangelog();
}

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env bun
import { $ } from 'bun';
import { formatReleaseNotes, generateChangelog, getContributors } from './generate-changelog';
const PACKAGE_NAME = 'cc-safety-net';
const bump = process.env.BUMP as 'major' | 'minor' | 'patch' | undefined;
const versionOverride = process.env.VERSION;
const dryRun = process.argv.includes('--dry-run');
console.log(`=== ${dryRun ? '[DRY-RUN] ' : ''}Publishing cc-safety-net ===\n`);
async function fetchPreviousVersion(): Promise<string> {
try {
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`);
const data = (await res.json()) as { version: string };
console.log(`Previous version: ${data.version}`);
return data.version;
} catch {
console.log('No previous version found, starting from 0.0.0');
return '0.0.0';
}
}
function bumpVersion(version: string, type: 'major' | 'minor' | 'patch'): string {
const parts = version.split('.').map((part) => Number(part));
const major = parts[0] ?? 0;
const minor = parts[1] ?? 0;
const patch = parts[2] ?? 0;
switch (type) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
return `${major}.${minor}.${patch + 1}`;
}
}
async function updatePackageVersion(newVersion: string): Promise<void> {
const pkgPath = new URL('../package.json', import.meta.url).pathname;
if (dryRun) {
console.log(`Would update: ${pkgPath}`);
return;
}
let pkg = await Bun.file(pkgPath).text();
pkg = pkg.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`);
await Bun.write(pkgPath, pkg);
console.log(`Updated: ${pkgPath}`);
}
async function updatePluginVersion(newVersion: string): Promise<void> {
const pluginPath = new URL('../.claude-plugin/plugin.json', import.meta.url).pathname;
if (dryRun) {
console.log(`Would update: ${pluginPath}`);
return;
}
let plugin = await Bun.file(pluginPath).text();
plugin = plugin.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`);
await Bun.write(pluginPath, plugin);
console.log(`Updated: ${pluginPath}`);
}
async function buildAndPublish(): Promise<void> {
// Build AFTER version files are updated so correct version is injected into bundle
console.log('\nBuilding...');
const buildResult = Bun.spawnSync(['bun', 'run', 'build']);
if (buildResult.exitCode !== 0) {
console.error('Build failed');
console.error(buildResult.stderr.toString());
process.exit(1);
}
if (dryRun) {
console.log('Would publish to npm');
return;
}
console.log('Publishing to npm...');
if (process.env.CI) {
await $`npm publish --access public --provenance --ignore-scripts`;
} else {
await $`npm publish --access public --ignore-scripts`;
}
}
async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> {
if (dryRun) {
console.log('\nWould commit, tag, push, and create GitHub release (CI only)');
return;
}
if (!process.env.CI) return;
console.log('\nCommitting and tagging...');
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
await $`git config user.name "github-actions[bot]"`;
await $`git add package.json .claude-plugin/plugin.json assets/cc-safety-net.schema.json`;
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow();
if (hasStagedChanges.exitCode !== 0) {
await $`git commit -m "release: v${newVersion}"`;
} else {
console.log('No changes to commit (version already updated)');
}
const tagExists = await $`git rev-parse v${newVersion}`.nothrow();
if (tagExists.exitCode !== 0) {
await $`git tag v${newVersion}`;
} else {
console.log(`Tag v${newVersion} already exists`);
}
await $`git push origin HEAD --tags`;
console.log('\nCreating GitHub release...');
const releaseNotes = notes.length > 0 ? notes.join('\n') : 'No notable changes';
const releaseExists = await $`gh release view v${newVersion}`.nothrow();
if (releaseExists.exitCode !== 0) {
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`;
} else {
console.log(`Release v${newVersion} already exists`);
}
}
async function checkVersionExists(version: string): Promise<boolean> {
try {
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/${version}`);
return res.ok;
} catch {
return false;
}
}
async function main(): Promise<void> {
const previous = await fetchPreviousVersion();
const newVersion =
versionOverride || (bump ? bumpVersion(previous, bump) : bumpVersion(previous, 'patch'));
console.log(`New version: ${newVersion}\n`);
if (await checkVersionExists(newVersion)) {
console.log(`Version ${newVersion} already exists on npm. Skipping publish.`);
process.exit(0);
}
await updatePackageVersion(newVersion);
await updatePluginVersion(newVersion);
const changelog = await generateChangelog(`v${previous}`);
const contributors = await getContributors(`v${previous}`);
const notes = formatReleaseNotes(changelog, contributors);
await buildAndPublish();
await gitTagAndRelease(newVersion, notes);
if (dryRun) {
console.log('\n--- Release Notes ---');
console.log(notes.length > 0 ? notes.join('\n') : 'No notable changes');
console.log(`\n=== [DRY-RUN] Would publish ${PACKAGE_NAME}@${newVersion} ===`);
} else {
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} ===`);
}
}
main();

View File

@@ -0,0 +1,6 @@
ruleDirs:
- ast-grep/rules
testConfigs:
- testDir: ast-grep/rule-tests
utilDirs:
- ast-grep/utils

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { runClaudeCodeHook } from './claude-code.ts';
import { CUSTOM_RULES_DOC } from './custom-rules-doc.ts';
import { runGeminiCLIHook } from './gemini-cli.ts';
import { printHelp, printVersion } from './help.ts';
import { printStatusline } from './statusline.ts';
import { verifyConfig } from './verify-config.ts';
function printCustomRulesDoc(): void {
console.log(CUSTOM_RULES_DOC);
}
type HookMode = 'claude-code' | 'gemini-cli' | 'statusline';
function handleCliFlags(): HookMode | null {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
if (args.includes('--version') || args.includes('-V')) {
printVersion();
process.exit(0);
}
if (args.includes('--verify-config') || args.includes('-vc')) {
process.exit(verifyConfig());
}
if (args.includes('--custom-rules-doc')) {
printCustomRulesDoc();
process.exit(0);
}
if (args.includes('--statusline')) {
return 'statusline';
}
if (args.includes('--claude-code') || args.includes('-cc')) {
return 'claude-code';
}
if (args.includes('--gemini-cli') || args.includes('-gc')) {
return 'gemini-cli';
}
console.error(`Unknown option: ${args[0]}`);
console.error("Run 'cc-safety-net --help' for usage.");
process.exit(1);
}
async function main(): Promise<void> {
const mode = handleCliFlags();
if (mode === 'claude-code') {
await runClaudeCodeHook();
} else if (mode === 'gemini-cli') {
await runGeminiCLIHook();
} else if (mode === 'statusline') {
await printStatusline();
}
}
main().catch((error: unknown) => {
console.error('Safety Net error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,81 @@
import { analyzeCommand, loadConfig } from '../core/analyze.ts';
import { redactSecrets, writeAuditLog } from '../core/audit.ts';
import { envTruthy } from '../core/env.ts';
import { formatBlockedMessage } from '../core/format.ts';
import type { HookInput, HookOutput } from '../types.ts';
function outputDeny(reason: string, command?: string, segment?: string): void {
const message = formatBlockedMessage({
reason,
command,
segment,
redact: redactSecrets,
});
const output: HookOutput = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
}
export async function runClaudeCodeHook(): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const inputText = Buffer.concat(chunks).toString('utf-8').trim();
if (!inputText) {
return;
}
let input: HookInput;
try {
input = JSON.parse(inputText) as HookInput;
} catch {
if (envTruthy('SAFETY_NET_STRICT')) {
outputDeny('Failed to parse hook input JSON (strict mode)');
}
return;
}
if (input.tool_name !== 'Bash') {
return;
}
const command = input.tool_input?.command;
if (!command) {
return;
}
const cwd = input.cwd ?? process.cwd();
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
const config = loadConfig(cwd);
const result = analyzeCommand(command, {
cwd,
config,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const sessionId = input.session_id;
if (sessionId) {
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
}
outputDeny(result.reason, command, result.segment);
}
}

View File

@@ -0,0 +1,116 @@
export const CUSTOM_RULES_DOC = `# Custom Rules Reference
Agent reference for generating \`.safety-net.json\` config files.
## Config Locations
| Scope | Path | Priority |
|-------|------|----------|
| User | \`~/.cc-safety-net/config.json\` | Lower |
| Project | \`.safety-net.json\` (cwd) | Higher (overrides user) |
Duplicate rule names (case-insensitive) → project wins.
## Schema
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [...]
}
\`\`\`
- \`$schema\`: Optional. Enables IDE autocomplete and inline validation.
- \`version\`: Required. Must be \`1\`.
- \`rules\`: Optional. Defaults to \`[]\`.
**Always include \`$schema\`** when generating config files for IDE support.
## Rule Fields
| Field | Required | Constraints |
|-------|----------|-------------|
| \`name\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$\` — unique (case-insensitive) |
| \`command\` | Yes | \`^[a-zA-Z][a-zA-Z0-9_-]*$\` — basename only, not path |
| \`subcommand\` | No | Same pattern as command. Omit to match any. |
| \`block_args\` | Yes | Non-empty array of non-empty strings |
| \`reason\` | Yes | Non-empty string, max 256 chars |
## Guidelines:
- \`name\`: kebab-case, descriptive (e.g., \`block-git-add-all\`)
- \`command\`: binary name only, lowercase
- \`subcommand\`: omit if rule applies to any subcommand
- \`block_args\`: include all variants (e.g., both \`-g\` and \`--global\`)
- \`reason\`: explain why blocked AND suggest alternative
## Matching Behavior
- **Command**: Normalized to basename (\`/usr/bin/git\`\`git\`)
- **Subcommand**: First non-option argument after command
- **Arguments**: Matched literally. Command blocked if **any** \`block_args\` item present.
- **Short options**: Expanded (\`-Ap\` matches \`-A\`)
- **Long options**: Exact match (\`--all-files\` does NOT match \`--all\`)
- **Execution order**: Built-in rules first, then custom rules (additive only)
## Examples
### Block \`git add -A\`
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-git-add-all",
"command": "git",
"subcommand": "add",
"block_args": ["-A", "--all", "."],
"reason": "Use 'git add <specific-files>' instead."
}
]
}
\`\`\`
### Block global npm install
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-npm-global",
"command": "npm",
"subcommand": "install",
"block_args": ["-g", "--global"],
"reason": "Use npx or local install."
}
]
}
\`\`\`
### Block docker system prune
\`\`\`json
{
"$schema": "https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json",
"version": 1,
"rules": [
{
"name": "block-docker-prune",
"command": "docker",
"subcommand": "system",
"block_args": ["prune"],
"reason": "Use targeted cleanup instead."
}
]
}
\`\`\`
## Error Handling
Invalid config → silent fallback to built-in rules only. No custom rules applied.
`;

View File

@@ -0,0 +1,84 @@
import { analyzeCommand, loadConfig } from '../core/analyze.ts';
import { redactSecrets, writeAuditLog } from '../core/audit.ts';
import { envTruthy } from '../core/env.ts';
import { formatBlockedMessage } from '../core/format.ts';
import type { GeminiHookInput, GeminiHookOutput } from '../types.ts';
function outputGeminiDeny(reason: string, command?: string, segment?: string): void {
const message = formatBlockedMessage({
reason,
command,
segment,
redact: redactSecrets,
});
// Gemini CLI expects exit code 0 with JSON for policy blocks; exit 2 is for hook errors.
const output: GeminiHookOutput = {
decision: 'deny',
reason: message,
systemMessage: message,
};
console.log(JSON.stringify(output));
}
export async function runGeminiCLIHook(): Promise<void> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
const inputText = Buffer.concat(chunks).toString('utf-8').trim();
if (!inputText) {
return;
}
let input: GeminiHookInput;
try {
input = JSON.parse(inputText) as GeminiHookInput;
} catch {
if (envTruthy('SAFETY_NET_STRICT')) {
outputGeminiDeny('Failed to parse hook input JSON (strict mode)');
}
return;
}
if (input.hook_event_name !== 'BeforeTool') {
return;
}
if (input.tool_name !== 'run_shell_command') {
return;
}
const command = input.tool_input?.command;
if (!command) {
return;
}
const cwd = input.cwd ?? process.cwd();
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
const config = loadConfig(cwd);
const result = analyzeCommand(command, {
cwd,
config,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const sessionId = input.session_id;
if (sessionId) {
writeAuditLog(sessionId, command, result.segment, result.reason, cwd);
}
outputGeminiDeny(result.reason, command, result.segment);
}
}

View File

@@ -0,0 +1,32 @@
declare const __PKG_VERSION__: string | undefined;
const version = typeof __PKG_VERSION__ !== 'undefined' ? __PKG_VERSION__ : 'dev';
export function printHelp(): void {
console.log(`cc-safety-net v${version}
Blocks destructive git and filesystem commands before execution.
USAGE:
cc-safety-net -cc, --claude-code Run as Claude Code PreToolUse hook (reads JSON from stdin)
cc-safety-net -gc, --gemini-cli Run as Gemini CLI BeforeTool hook (reads JSON from stdin)
cc-safety-net -vc, --verify-config Validate config files
cc-safety-net --custom-rules-doc Print custom rules documentation
cc-safety-net --statusline Print status line with mode indicators
cc-safety-net -h, --help Show this help
cc-safety-net -V, --version Show version
ENVIRONMENT VARIABLES:
SAFETY_NET_STRICT=1 Fail-closed on unparseable commands
SAFETY_NET_PARANOID=1 Enable all paranoid checks
SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd
SAFETY_NET_PARANOID_INTERPRETERS=1 Block interpreter one-liners
CONFIG FILES:
~/.cc-safety-net/config.json User-scope config
.safety-net.json Project-scope config`);
}
export function printVersion(): void {
console.log(version);
}

View File

@@ -0,0 +1,117 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { envTruthy } from '../core/env.ts';
/**
* Read piped stdin content asynchronously.
* Returns null if stdin is a TTY (no piped input) or empty.
*/
async function readStdinAsync(): Promise<string | null> {
if (process.stdin.isTTY) {
return null;
}
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => {
const trimmed = data.trim();
resolve(trimmed || null);
});
process.stdin.on('error', () => {
resolve(null);
});
});
}
function getSettingsPath(): string {
// Allow override for testing
if (process.env.CLAUDE_SETTINGS_PATH) {
return process.env.CLAUDE_SETTINGS_PATH;
}
return join(homedir(), '.claude', 'settings.json');
}
interface ClaudeSettings {
enabledPlugins?: Record<string, boolean>;
}
function isPluginEnabled(): boolean {
const settingsPath = getSettingsPath();
if (!existsSync(settingsPath)) {
// Default to disabled if settings file doesn't exist
return false;
}
try {
const content = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content) as ClaudeSettings;
// If enabledPlugins doesn't exist or plugin not listed, default to disabled
if (!settings.enabledPlugins) {
return false;
}
const pluginKey = 'safety-net@cc-marketplace';
// If not explicitly set, default to disabled
if (!(pluginKey in settings.enabledPlugins)) {
return false;
}
return settings.enabledPlugins[pluginKey] === true;
} catch {
// On any error (invalid JSON, etc.), default to disabled
return false;
}
}
export async function printStatusline(): Promise<void> {
const enabled = isPluginEnabled();
// Build our status string
let status: string;
if (!enabled) {
status = '🛡️ Safety Net ❌';
} else {
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
let modeEmojis = '';
// Strict mode: 🔒
if (strict) {
modeEmojis += '🔒';
}
// Paranoid modes: 👁️ if PARANOID or (PARANOID_RM + PARANOID_INTERPRETERS)
// Otherwise individual emojis: 🗑️ for RM, 🐚 for interpreters
if (paranoidAll || (paranoidRm && paranoidInterpreters)) {
modeEmojis += '👁️';
} else if (paranoidRm) {
modeEmojis += '🗑️';
} else if (paranoidInterpreters) {
modeEmojis += '🐚';
}
// If no mode flags, show ✅
const statusEmoji = modeEmojis || '✅';
status = `🛡️ Safety Net ${statusEmoji}`;
}
// Check for piped stdin input and prepend with separator
// Skip JSON input (Claude Code pipes status JSON that shouldn't be echoed)
const stdinInput = await readStdinAsync();
if (stdinInput && !stdinInput.startsWith('{')) {
console.log(`${stdinInput} | ${status}`);
} else {
console.log(status);
}
}

View File

@@ -0,0 +1,132 @@
/**
* Verify user and project scope config files for safety-net.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
getProjectConfigPath,
getUserConfigPath,
type ValidationResult,
validateConfigFile,
} from '../core/config.ts';
export interface VerifyConfigOptions {
userConfigPath?: string;
projectConfigPath?: string;
}
const HEADER = 'Safety Net Config';
const SEPARATOR = '═'.repeat(HEADER.length);
const SCHEMA_URL =
'https://raw.githubusercontent.com/kenryu42/claude-code-safety-net/main/assets/cc-safety-net.schema.json';
function printHeader(): void {
console.log(HEADER);
console.log(SEPARATOR);
}
function printValidConfig(scope: string, path: string, result: ValidationResult): void {
console.log(`\n✓ ${scope} config: ${path}`);
if (result.ruleNames.size > 0) {
console.log(' Rules:');
let i = 1;
for (const name of result.ruleNames) {
console.log(` ${i}. ${name}`);
i++;
}
} else {
console.log(' Rules: (none)');
}
}
function printInvalidConfig(scope: string, path: string, errors: string[]): void {
console.error(`\n✗ ${scope} config: ${path}`);
console.error(' Errors:');
let errorNum = 1;
for (const error of errors) {
for (const part of error.split('; ')) {
console.error(` ${errorNum}. ${part}`);
errorNum++;
}
}
}
function addSchemaIfMissing(path: string): boolean {
try {
const content = readFileSync(path, 'utf-8');
const parsed = JSON.parse(content) as Record<string, unknown>;
if (parsed.$schema) {
return false;
}
const updated = { $schema: SCHEMA_URL, ...parsed };
writeFileSync(path, JSON.stringify(updated, null, 2), 'utf-8');
return true;
} catch {
return false;
}
}
/**
* Verify config files and print results.
* @returns Exit code (0 = success, 1 = errors found)
*/
export function verifyConfig(options: VerifyConfigOptions = {}): number {
const userConfig = options.userConfigPath ?? getUserConfigPath();
const projectConfig = options.projectConfigPath ?? getProjectConfigPath();
let hasErrors = false;
const configsChecked: Array<{
scope: string;
path: string;
result: ValidationResult;
}> = [];
printHeader();
if (existsSync(userConfig)) {
const result = validateConfigFile(userConfig);
configsChecked.push({ scope: 'User', path: userConfig, result });
if (result.errors.length > 0) {
hasErrors = true;
}
}
if (existsSync(projectConfig)) {
const result = validateConfigFile(projectConfig);
configsChecked.push({
scope: 'Project',
path: resolve(projectConfig),
result,
});
if (result.errors.length > 0) {
hasErrors = true;
}
}
if (configsChecked.length === 0) {
console.log('\nNo config files found. Using built-in rules only.');
return 0;
}
for (const { scope, path, result } of configsChecked) {
if (result.errors.length > 0) {
printInvalidConfig(scope, path, result.errors);
} else {
if (addSchemaIfMissing(path)) {
console.log(`\nAdded $schema to ${scope.toLowerCase()} config.`);
}
printValidConfig(scope, path, result);
}
}
if (hasErrors) {
console.error('\nConfig validation failed.');
return 1;
}
console.log('\nAll configs valid.');
return 0;
}

View File

@@ -0,0 +1,32 @@
import type { AnalyzeOptions, AnalyzeResult } from '../types.ts';
import { analyzeCommandInternal } from './analyze/analyze-command.ts';
import { findHasDelete } from './analyze/find.ts';
import { extractParallelChildCommand } from './analyze/parallel.ts';
import { hasRecursiveForceFlags } from './analyze/rm-flags.ts';
import { segmentChangesCwd } from './analyze/segment.ts';
import { extractXargsChildCommand, extractXargsChildCommandWithInfo } from './analyze/xargs.ts';
import { loadConfig } from './config.ts';
export function analyzeCommand(
command: string,
options: AnalyzeOptions = {},
): AnalyzeResult | null {
const config = options.config ?? loadConfig(options.cwd);
return analyzeCommandInternal(command, 0, { ...options, config });
}
export { loadConfig };
/** @internal Exported for testing */
export { findHasDelete as _findHasDelete };
/** @internal Exported for testing */
export { extractParallelChildCommand as _extractParallelChildCommand };
/** @internal Exported for testing */
export { hasRecursiveForceFlags as _hasRecursiveForceFlags };
/** @internal Exported for testing */
export { segmentChangesCwd as _segmentChangesCwd };
/** @internal Exported for testing */
export { extractXargsChildCommand as _extractXargsChildCommand };
/** @internal Exported for testing */
export { extractXargsChildCommandWithInfo as _extractXargsChildCommandWithInfo };

View File

@@ -0,0 +1,79 @@
import {
type AnalyzeOptions,
type AnalyzeResult,
type Config,
MAX_RECURSION_DEPTH,
} from '../../types.ts';
import { splitShellCommands } from '../shell.ts';
import { dangerousInText } from './dangerous-text.ts';
import { analyzeSegment, segmentChangesCwd } from './segment.ts';
const REASON_STRICT_UNPARSEABLE =
'Command could not be safely analyzed (strict mode). Verify manually.';
const REASON_RECURSION_LIMIT =
'Command exceeds maximum recursion depth and cannot be safely analyzed.';
export type InternalOptions = AnalyzeOptions & { config: Config };
export function analyzeCommandInternal(
command: string,
depth: number,
options: InternalOptions,
): AnalyzeResult | null {
if (depth >= MAX_RECURSION_DEPTH) {
return { reason: REASON_RECURSION_LIMIT, segment: command };
}
const segments = splitShellCommands(command);
// Strict mode: block if command couldn't be parsed (unclosed quotes, etc.)
// Detected when splitShellCommands returns a single segment containing the raw command
if (
options.strict &&
segments.length === 1 &&
segments[0]?.length === 1 &&
segments[0][0] === command &&
command.includes(' ')
) {
return { reason: REASON_STRICT_UNPARSEABLE, segment: command };
}
const originalCwd = options.cwd;
let effectiveCwd: string | null | undefined = options.cwd;
for (const segment of segments) {
const segmentStr = segment.join(' ');
if (segment.length === 1 && segment[0]?.includes(' ')) {
const textReason = dangerousInText(segment[0]);
if (textReason) {
return { reason: textReason, segment: segmentStr };
}
if (segmentChangesCwd(segment)) {
effectiveCwd = null;
}
continue;
}
const reason = analyzeSegment(segment, depth, {
...options,
cwd: originalCwd,
effectiveCwd,
analyzeNested: (nestedCommand: string): string | null => {
return analyzeCommandInternal(nestedCommand, depth + 1, options)?.reason ?? null;
},
});
if (reason) {
return { reason, segment: segmentStr };
}
if (segmentChangesCwd(segment)) {
effectiveCwd = null;
}
}
return null;
}

View File

@@ -0,0 +1,101 @@
export const DISPLAY_COMMANDS: ReadonlySet<string> = new Set([
'echo',
'printf',
'cat',
'head',
'tail',
'less',
'more',
'grep',
'rg',
'ag',
'ack',
'sed',
'awk',
'cut',
'tr',
'sort',
'uniq',
'wc',
'tee',
'man',
'help',
'info',
'type',
'which',
'whereis',
'whatis',
'apropos',
'file',
'stat',
'ls',
'll',
'dir',
'tree',
'pwd',
'date',
'cal',
'uptime',
'whoami',
'id',
'groups',
'hostname',
'uname',
'env',
'printenv',
'set',
'export',
'alias',
'history',
'jobs',
'fg',
'bg',
'test',
'true',
'false',
'read',
'return',
'exit',
'break',
'continue',
'shift',
'wait',
'trap',
'basename',
'dirname',
'realpath',
'readlink',
'md5sum',
'sha256sum',
'base64',
'xxd',
'od',
'hexdump',
'strings',
'diff',
'cmp',
'comm',
'join',
'paste',
'column',
'fmt',
'fold',
'nl',
'pr',
'expand',
'unexpand',
'rev',
'tac',
'shuf',
'seq',
'yes',
'timeout',
'time',
'sleep',
'watch',
'logger',
'write',
'wall',
'mesg',
'notify-send',
]);

View File

@@ -0,0 +1,64 @@
export function dangerousInText(text: string): string | null {
const t = text.toLowerCase();
const stripped = t.trimStart();
const isEchoOrRg = stripped.startsWith('echo ') || stripped.startsWith('rg ');
const patterns: Array<{
regex: RegExp;
reason: string;
skipForEchoRg?: boolean;
caseSensitive?: boolean;
}> = [
{
regex: /\brm\s+(-[^\s]*r[^\s]*\s+-[^\s]*f|-[^\s]*f[^\s]*\s+-[^\s]*r|-[^\s]*rf|-[^\s]*fr)\b/,
reason: 'rm -rf',
},
{
regex: /\bgit\s+reset\s+--hard\b/,
reason: 'git reset --hard',
},
{
regex: /\bgit\s+reset\s+--merge\b/,
reason: 'git reset --merge',
},
{
regex: /\bgit\s+clean\s+(-[^\s]*f|-f)\b/,
reason: 'git clean -f',
},
{
regex: /\bgit\s+push\s+[^|;]*(-f\b|--force\b)(?!-with-lease)/,
reason: 'git push --force (use --force-with-lease instead)',
},
{
regex: /\bgit\s+branch\s+-D\b/,
reason: 'git branch -D',
caseSensitive: true,
},
{
regex: /\bgit\s+stash\s+(drop|clear)\b/,
reason: 'git stash drop/clear',
},
{
regex: /\bgit\s+checkout\s+--\s/,
reason: 'git checkout --',
},
{
regex: /\bgit\s+restore\b(?!.*--(staged|help))/,
reason: 'git restore (without --staged)',
},
{
regex: /\bfind\b[^\n;|&]*\s-delete\b/,
reason: 'find -delete',
skipForEchoRg: true,
},
];
for (const { regex, reason, skipForEchoRg, caseSensitive } of patterns) {
if (skipForEchoRg && isEchoOrRg) continue;
const target = caseSensitive ? text : t;
if (regex.test(target)) {
return reason;
}
}
return null;
}

View File

@@ -0,0 +1,125 @@
import { getBasename, stripWrappers } from '../shell.ts';
import { hasRecursiveForceFlags } from './rm-flags.ts';
const REASON_FIND_DELETE = 'find -delete permanently removes files. Use -print first to preview.';
export function analyzeFind(tokens: readonly string[]): string | null {
// Check for -delete outside of -exec/-execdir blocks
if (findHasDelete(tokens.slice(1))) {
return REASON_FIND_DELETE;
}
// Check all -exec and -execdir blocks for dangerous commands
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === '-exec' || token === '-execdir') {
const execTokens = tokens.slice(i + 1);
const semicolonIdx = execTokens.indexOf(';');
const plusIdx = execTokens.indexOf('+');
// If no terminator found, shell-quote may have parsed it as an operator
// In that case, treat the rest of the tokens as the exec command
const endIdx =
semicolonIdx !== -1 && plusIdx !== -1
? Math.min(semicolonIdx, plusIdx)
: semicolonIdx !== -1
? semicolonIdx
: plusIdx !== -1
? plusIdx
: execTokens.length; // No terminator - use all remaining tokens
let execCommand = execTokens.slice(0, endIdx);
// Strip wrappers (env, sudo, command)
execCommand = stripWrappers(execCommand);
if (execCommand.length > 0) {
let head = getBasename(execCommand[0] ?? '');
// Handle busybox wrapper
if (head === 'busybox' && execCommand.length > 1) {
execCommand = execCommand.slice(1);
head = getBasename(execCommand[0] ?? '');
}
if (head === 'rm' && hasRecursiveForceFlags(execCommand)) {
return 'find -exec rm -rf is dangerous. Use explicit file list instead.';
}
}
}
}
return null;
}
/**
* Check if find command has -delete action (not as argument to another option).
* Handles cases like "find -name -delete" where -delete is a filename pattern.
*/
export function findHasDelete(tokens: readonly string[]): boolean {
let i = 0;
let insideExec = false;
let execDepth = 0;
while (i < tokens.length) {
const token = tokens[i];
if (!token) {
i++;
continue;
}
// Track -exec/-execdir blocks
if (token === '-exec' || token === '-execdir') {
insideExec = true;
execDepth++;
i++;
continue;
}
// End of -exec block
if (insideExec && (token === ';' || token === '+')) {
execDepth--;
if (execDepth === 0) {
insideExec = false;
}
i++;
continue;
}
// Skip -delete inside -exec blocks
if (insideExec) {
i++;
continue;
}
// Options that take an argument - skip the next token
if (
token === '-name' ||
token === '-iname' ||
token === '-path' ||
token === '-ipath' ||
token === '-regex' ||
token === '-iregex' ||
token === '-type' ||
token === '-user' ||
token === '-group' ||
token === '-perm' ||
token === '-size' ||
token === '-mtime' ||
token === '-ctime' ||
token === '-atime' ||
token === '-newer' ||
token === '-printf' ||
token === '-fprint' ||
token === '-fprintf'
) {
i += 2; // Skip option and its argument
continue;
}
// Found -delete outside of -exec and not as an argument
if (token === '-delete') {
return true;
}
i++;
}
return false;
}

View File

@@ -0,0 +1,22 @@
import { DANGEROUS_PATTERNS } from '../../types.ts';
export function extractInterpreterCodeArg(tokens: readonly string[]): string | null {
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if ((token === '-c' || token === '-e') && tokens[i + 1]) {
return tokens[i + 1] ?? null;
}
}
return null;
}
export function containsDangerousCode(code: string): boolean {
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(code)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,337 @@
import { SHELL_WRAPPERS } from '../../types.ts';
import { analyzeGit } from '../rules-git.ts';
import { analyzeRm } from '../rules-rm.ts';
import { getBasename, stripWrappers } from '../shell.ts';
import { analyzeFind } from './find.ts';
import { hasRecursiveForceFlags } from './rm-flags.ts';
import { extractDashCArg } from './shell-wrappers.ts';
const REASON_PARALLEL_RM =
'parallel rm -rf with dynamic input is dangerous. Use explicit file list instead.';
const REASON_PARALLEL_SHELL =
'parallel with shell -c can execute arbitrary commands from dynamic input.';
export interface ParallelAnalyzeContext {
cwd: string | undefined;
originalCwd: string | undefined;
paranoidRm: boolean | undefined;
allowTmpdirVar: boolean;
analyzeNested: (command: string) => string | null;
}
export function analyzeParallel(
tokens: readonly string[],
context: ParallelAnalyzeContext,
): string | null {
const parseResult = parseParallelCommand(tokens);
if (!parseResult) {
return null;
}
const { template, args, hasPlaceholder } = parseResult;
if (template.length === 0) {
// parallel ::: 'cmd1' 'cmd2' - commands mode
// Analyze each arg as a command
for (const arg of args) {
const reason = context.analyzeNested(arg);
if (reason) {
return reason;
}
}
return null;
}
let childTokens = stripWrappers([...template]);
let head = getBasename(childTokens[0] ?? '').toLowerCase();
if (head === 'busybox' && childTokens.length > 1) {
childTokens = childTokens.slice(1);
head = getBasename(childTokens[0] ?? '').toLowerCase();
}
// Check for shell wrapper with -c
if (SHELL_WRAPPERS.has(head)) {
const dashCArg = extractDashCArg(childTokens);
if (dashCArg) {
// If script IS just the placeholder, stdin provides entire script - dangerous
if (dashCArg === '{}' || dashCArg === '{1}') {
return REASON_PARALLEL_SHELL;
}
// If script contains placeholder
if (dashCArg.includes('{}')) {
if (args.length > 0) {
// Expand with actual args and analyze
for (const arg of args) {
const expandedScript = dashCArg.replace(/{}/g, arg);
const reason = context.analyzeNested(expandedScript);
if (reason) {
return reason;
}
}
return null;
}
// Stdin mode with placeholder - analyze the script template
// Check if the script pattern is dangerous (e.g., rm -rf {})
const reason = context.analyzeNested(dashCArg);
if (reason) {
return reason;
}
return null;
}
// Script doesn't have placeholder - analyze it directly
const reason = context.analyzeNested(dashCArg);
if (reason) {
return reason;
}
// If there's a placeholder in the shell wrapper args (not script),
// it's still dangerous
if (hasPlaceholder) {
return REASON_PARALLEL_SHELL;
}
return null;
}
// bash -c without script argument
// If there are args from :::, those become the scripts - dangerous pattern
if (args.length > 0) {
// The pattern of passing scripts via ::: to bash -c is inherently dangerous
return REASON_PARALLEL_SHELL;
}
// Stdin provides the script - dangerous
if (hasPlaceholder) {
return REASON_PARALLEL_SHELL;
}
return null;
}
// For rm -rf, expand with actual args and analyze each expansion
if (head === 'rm' && hasRecursiveForceFlags(childTokens)) {
if (hasPlaceholder && args.length > 0) {
// Expand template with each arg and analyze
for (const arg of args) {
const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg));
const rmResult = analyzeRm(expandedTokens, {
cwd: context.cwd,
originalCwd: context.originalCwd,
paranoid: context.paranoidRm,
allowTmpdirVar: context.allowTmpdirVar,
});
if (rmResult) {
return rmResult;
}
}
return null;
}
// No placeholder or no args - analyze template as-is
// If there are args (from :::), they get appended, analyze with first arg
if (args.length > 0) {
const expandedTokens = [...childTokens, args[0] ?? ''];
const rmResult = analyzeRm(expandedTokens, {
cwd: context.cwd,
originalCwd: context.originalCwd,
paranoid: context.paranoidRm,
allowTmpdirVar: context.allowTmpdirVar,
});
if (rmResult) {
return rmResult;
}
return null;
}
return REASON_PARALLEL_RM;
}
if (head === 'find') {
const findResult = analyzeFind(childTokens);
if (findResult) {
return findResult;
}
}
if (head === 'git') {
const gitResult = analyzeGit(childTokens);
if (gitResult) {
return gitResult;
}
}
return null;
}
interface ParallelParseResult {
template: string[];
args: string[];
hasPlaceholder: boolean;
}
function parseParallelCommand(tokens: readonly string[]): ParallelParseResult | null {
// Options that take a value as the next token
const parallelOptsWithValue = new Set([
'-S',
'--sshlogin',
'--slf',
'--sshloginfile',
'-a',
'--arg-file',
'--colsep',
'-I',
'--replace',
'--results',
'--result',
'--res',
]);
let i = 1;
const templateTokens: string[] = [];
let markerIndex = -1;
// First pass: find the ::: marker and extract template
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === ':::') {
markerIndex = i;
break;
}
if (token === '--') {
// Everything after -- until ::: is the template
i++;
while (i < tokens.length) {
const token = tokens[i];
if (token === undefined || token === ':::') break;
templateTokens.push(token);
i++;
}
if (i < tokens.length && tokens[i] === ':::') {
markerIndex = i;
}
break;
}
if (token.startsWith('-')) {
// Handle -jN attached option
if (token.startsWith('-j') && token.length > 2 && /^\d+$/.test(token.slice(2))) {
i++;
continue;
}
// Handle --option=value
if (token.startsWith('--') && token.includes('=')) {
i++;
continue;
}
// Handle options that take a value
if (parallelOptsWithValue.has(token)) {
i += 2;
continue;
}
// Handle -j as separate option
if (token === '-j' || token === '--jobs') {
i += 2;
continue;
}
// Unknown option - skip it
i++;
} else {
// Start of template
while (i < tokens.length) {
const token = tokens[i];
if (token === undefined || token === ':::') break;
templateTokens.push(token);
i++;
}
if (i < tokens.length && tokens[i] === ':::') {
markerIndex = i;
}
break;
}
}
// Extract args after :::
const args: string[] = [];
if (markerIndex !== -1) {
for (let j = markerIndex + 1; j < tokens.length; j++) {
const token = tokens[j];
if (token && token !== ':::') {
args.push(token);
}
}
}
// Determine if template has placeholder
const hasPlaceholder = templateTokens.some(
(t) => t.includes('{}') || t.includes('{1}') || t.includes('{.}'),
);
// If no template and no marker, no valid parallel command
if (templateTokens.length === 0 && markerIndex === -1) {
return null;
}
return { template: templateTokens, args, hasPlaceholder };
}
export function extractParallelChildCommand(tokens: readonly string[]): string[] {
// Legacy behavior: return everything after options until end
// This includes ::: marker and args if present
const parallelOptsWithValue = new Set([
'-S',
'--sshlogin',
'--slf',
'--sshloginfile',
'-a',
'--arg-file',
'--colsep',
'-I',
'--replace',
'--results',
'--result',
'--res',
]);
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === ':::') {
// ::: as first non-option means no template
return [];
}
if (token === '--') {
return [...tokens.slice(i + 1)];
}
if (token.startsWith('-')) {
if (token.startsWith('-j') && token.length > 2 && /^\d+$/.test(token.slice(2))) {
i++;
continue;
}
if (token.startsWith('--') && token.includes('=')) {
i++;
continue;
}
if (parallelOptsWithValue.has(token)) {
i += 2;
continue;
}
if (token === '-j' || token === '--jobs') {
i += 2;
continue;
}
i++;
} else {
// Return everything from here to end (including ::: and args)
return [...tokens.slice(i)];
}
}
return [];
}

View File

@@ -0,0 +1,19 @@
export function hasRecursiveForceFlags(tokens: readonly string[]): boolean {
let hasRecursive = false;
let hasForce = false;
for (const token of tokens) {
if (token === '--') break;
if (token === '-r' || token === '-R' || token === '--recursive') {
hasRecursive = true;
} else if (token === '-f' || token === '--force') {
hasForce = true;
} else if (token.startsWith('-') && !token.startsWith('--')) {
if (token.includes('r') || token.includes('R')) hasRecursive = true;
if (token.includes('f')) hasForce = true;
}
}
return hasRecursive && hasForce;
}

View File

@@ -0,0 +1,264 @@
import {
type AnalyzeOptions,
type Config,
INTERPRETERS,
PARANOID_INTERPRETERS_SUFFIX,
SHELL_WRAPPERS,
} from '../../types.ts';
import { checkCustomRules } from '../rules-custom.ts';
import { analyzeGit } from '../rules-git.ts';
import { analyzeRm, isHomeDirectory } from '../rules-rm.ts';
import {
getBasename,
normalizeCommandToken,
stripEnvAssignmentsWithInfo,
stripWrappers,
stripWrappersWithInfo,
} from '../shell.ts';
import { DISPLAY_COMMANDS } from './constants.ts';
import { analyzeFind } from './find.ts';
import { containsDangerousCode, extractInterpreterCodeArg } from './interpreters.ts';
import { analyzeParallel } from './parallel.ts';
import { hasRecursiveForceFlags } from './rm-flags.ts';
import { extractDashCArg } from './shell-wrappers.ts';
import { isTmpdirOverriddenToNonTemp } from './tmpdir.ts';
import { analyzeXargs } from './xargs.ts';
const REASON_INTERPRETER_DANGEROUS = 'Detected potentially dangerous command in interpreter code.';
const REASON_INTERPRETER_BLOCKED = 'Interpreter one-liners are blocked in paranoid mode.';
const REASON_RM_HOME_CWD =
'rm -rf in home directory is dangerous. Change to a project directory first.';
export type InternalOptions = AnalyzeOptions & {
config: Config;
effectiveCwd: string | null | undefined;
analyzeNested: (command: string) => string | null;
};
function deriveCwdContext(options: Pick<InternalOptions, 'cwd' | 'effectiveCwd'>): {
cwdUnknown: boolean;
cwdForRm: string | undefined;
originalCwd: string | undefined;
} {
const cwdUnknown = options.effectiveCwd === null;
const cwdForRm = cwdUnknown ? undefined : (options.effectiveCwd ?? options.cwd);
const originalCwd = cwdUnknown ? undefined : options.cwd;
return { cwdUnknown, cwdForRm, originalCwd };
}
export function analyzeSegment(
tokens: string[],
depth: number,
options: InternalOptions,
): string | null {
if (tokens.length === 0) {
return null;
}
const { tokens: strippedEnv, envAssignments: leadingEnvAssignments } =
stripEnvAssignmentsWithInfo(tokens);
const { tokens: stripped, envAssignments: wrapperEnvAssignments } =
stripWrappersWithInfo(strippedEnv);
const envAssignments = new Map(leadingEnvAssignments);
for (const [k, v] of wrapperEnvAssignments) {
envAssignments.set(k, v);
}
if (stripped.length === 0) {
return null;
}
const head = stripped[0];
if (!head) {
return null;
}
const normalizedHead = normalizeCommandToken(head);
const basename = getBasename(head);
const { cwdForRm, originalCwd } = deriveCwdContext(options);
const allowTmpdirVar = !isTmpdirOverriddenToNonTemp(envAssignments);
if (SHELL_WRAPPERS.has(normalizedHead)) {
const dashCArg = extractDashCArg(stripped);
if (dashCArg) {
return options.analyzeNested(dashCArg);
}
}
if (INTERPRETERS.has(normalizedHead)) {
const codeArg = extractInterpreterCodeArg(stripped);
if (codeArg) {
if (options.paranoidInterpreters) {
return REASON_INTERPRETER_BLOCKED + PARANOID_INTERPRETERS_SUFFIX;
}
const innerReason = options.analyzeNested(codeArg);
if (innerReason) {
return innerReason;
}
if (containsDangerousCode(codeArg)) {
return REASON_INTERPRETER_DANGEROUS;
}
}
}
if (normalizedHead === 'busybox' && stripped.length > 1) {
return analyzeSegment(stripped.slice(1), depth, options);
}
const isGit = basename.toLowerCase() === 'git';
const isRm = basename === 'rm';
const isFind = basename === 'find';
const isXargs = basename === 'xargs';
const isParallel = basename === 'parallel';
if (isGit) {
const gitResult = analyzeGit(stripped);
if (gitResult) {
return gitResult;
}
}
if (isRm) {
if (cwdForRm && isHomeDirectory(cwdForRm)) {
if (hasRecursiveForceFlags(stripped)) {
return REASON_RM_HOME_CWD;
}
}
const rmResult = analyzeRm(stripped, {
cwd: cwdForRm,
originalCwd,
paranoid: options.paranoidRm,
allowTmpdirVar,
});
if (rmResult) {
return rmResult;
}
}
if (isFind) {
const findResult = analyzeFind(stripped);
if (findResult) {
return findResult;
}
}
if (isXargs) {
const xargsResult = analyzeXargs(stripped, {
cwd: cwdForRm,
originalCwd,
paranoidRm: options.paranoidRm,
allowTmpdirVar,
});
if (xargsResult) {
return xargsResult;
}
}
if (isParallel) {
const parallelResult = analyzeParallel(stripped, {
cwd: cwdForRm,
originalCwd,
paranoidRm: options.paranoidRm,
allowTmpdirVar,
analyzeNested: options.analyzeNested,
});
if (parallelResult) {
return parallelResult;
}
}
const matchedKnown = isGit || isRm || isFind || isXargs || isParallel;
if (!matchedKnown) {
// Fallback: scan tokens for embedded git/rm/find commands
// This catches cases like "command -px git reset --hard" where the head
// token is not a known command but contains dangerous commands later
// Skip for display-only commands that don't execute their arguments
if (!DISPLAY_COMMANDS.has(normalizedHead)) {
for (let i = 1; i < stripped.length; i++) {
const token = stripped[i];
if (!token) continue;
const cmd = normalizeCommandToken(token);
if (cmd === 'rm') {
const rmTokens = ['rm', ...stripped.slice(i + 1)];
const reason = analyzeRm(rmTokens, {
cwd: cwdForRm,
originalCwd,
paranoid: options.paranoidRm,
allowTmpdirVar,
});
if (reason) {
return reason;
}
}
if (cmd === 'git') {
const gitTokens = ['git', ...stripped.slice(i + 1)];
const reason = analyzeGit(gitTokens);
if (reason) {
return reason;
}
}
if (cmd === 'find') {
const findTokens = ['find', ...stripped.slice(i + 1)];
const reason = analyzeFind(findTokens);
if (reason) {
return reason;
}
}
}
}
}
const customRulesTopLevelOnly = isGit || isRm || isFind || isXargs || isParallel;
if (depth === 0 || !customRulesTopLevelOnly) {
const customResult = checkCustomRules(stripped, options.config.rules);
if (customResult) {
return customResult;
}
}
return null;
}
const CWD_CHANGE_REGEX =
/^\s*(?:\$\(\s*)?[({]*\s*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)(?:\s|$)/;
export function segmentChangesCwd(segment: readonly string[]): boolean {
const stripped = stripLeadingGrouping(segment);
const unwrapped = stripWrappers([...stripped]);
if (unwrapped.length === 0) {
return false;
}
let head = unwrapped[0] ?? '';
if (head === 'builtin' && unwrapped.length > 1) {
head = unwrapped[1] ?? '';
}
if (head === 'cd' || head === 'pushd' || head === 'popd') {
return true;
}
const joined = segment.join(' ');
return CWD_CHANGE_REGEX.test(joined);
}
function stripLeadingGrouping(tokens: readonly string[]): readonly string[] {
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token === '{' || token === '(' || token === '$(') {
i++;
} else {
break;
}
}
return tokens.slice(i);
}

View File

@@ -0,0 +1,18 @@
export function extractDashCArg(tokens: readonly string[]): string | null {
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (token === '-c' && tokens[i + 1]) {
return tokens[i + 1] ?? null;
}
if (token.startsWith('-') && token.includes('c') && !token.startsWith('--')) {
const nextToken = tokens[i + 1];
if (nextToken && !nextToken.startsWith('-')) {
return nextToken;
}
}
}
return null;
}

View File

@@ -0,0 +1,38 @@
import { tmpdir } from 'node:os';
export function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>): boolean {
if (!envAssignments.has('TMPDIR')) {
return false;
}
const tmpdirValue = envAssignments.get('TMPDIR') ?? '';
// Empty TMPDIR is dangerous: $TMPDIR/foo expands to /foo
if (tmpdirValue === '') {
return true;
}
// Check if it's a known temp path (exact match or subpath)
const sysTmpdir = tmpdir();
if (
isPathOrSubpath(tmpdirValue, '/tmp') ||
isPathOrSubpath(tmpdirValue, '/var/tmp') ||
isPathOrSubpath(tmpdirValue, sysTmpdir)
) {
return false;
}
return true;
}
/**
* Check if a path equals or is a subpath of basePath.
* E.g., isPathOrSubpath("/tmp/foo", "/tmp") → true
* isPathOrSubpath("/tmp-malicious", "/tmp") → false
*/
function isPathOrSubpath(path: string, basePath: string): boolean {
if (path === basePath) {
return true;
}
// Ensure basePath ends with / for proper prefix matching
const baseWithSlash = basePath.endsWith('/') ? basePath : `${basePath}/`;
return path.startsWith(baseWithSlash);
}

View File

@@ -0,0 +1,180 @@
import { SHELL_WRAPPERS } from '../../types.ts';
import { analyzeGit } from '../rules-git.ts';
import { analyzeRm } from '../rules-rm.ts';
import { getBasename, stripWrappers } from '../shell.ts';
import { analyzeFind } from './find.ts';
import { hasRecursiveForceFlags } from './rm-flags.ts';
const REASON_XARGS_RM =
'xargs rm -rf with dynamic input is dangerous. Use explicit file list instead.';
const REASON_XARGS_SHELL = 'xargs with shell -c can execute arbitrary commands from dynamic input.';
export interface XargsAnalyzeContext {
cwd: string | undefined;
originalCwd: string | undefined;
paranoidRm: boolean | undefined;
allowTmpdirVar: boolean;
}
export function analyzeXargs(
tokens: readonly string[],
context: XargsAnalyzeContext,
): string | null {
const { childTokens: rawChildTokens } = extractXargsChildCommandWithInfo(tokens);
let childTokens = stripWrappers(rawChildTokens);
if (childTokens.length === 0) {
return null;
}
let head = getBasename(childTokens[0] ?? '').toLowerCase();
if (head === 'busybox' && childTokens.length > 1) {
childTokens = childTokens.slice(1);
head = getBasename(childTokens[0] ?? '').toLowerCase();
}
// Check for shell wrapper with -c
if (SHELL_WRAPPERS.has(head)) {
// xargs bash -c is always dangerous - stdin feeds into the shell execution
// Either no script arg (stdin IS the script) or script with dynamic input
return REASON_XARGS_SHELL;
}
if (head === 'rm' && hasRecursiveForceFlags(childTokens)) {
const rmResult = analyzeRm(childTokens, {
cwd: context.cwd,
originalCwd: context.originalCwd,
paranoid: context.paranoidRm,
allowTmpdirVar: context.allowTmpdirVar,
});
if (rmResult) {
return rmResult;
}
// Even if analyzeRm passes (e.g., temp paths), xargs rm -rf is still dangerous
// because stdin provides dynamic input
return REASON_XARGS_RM;
}
if (head === 'find') {
const findResult = analyzeFind(childTokens);
if (findResult) {
return findResult;
}
}
if (head === 'git') {
const gitResult = analyzeGit(childTokens);
if (gitResult) {
return gitResult;
}
}
return null;
}
interface XargsParseResult {
childTokens: string[];
replacementToken: string | null;
}
export function extractXargsChildCommandWithInfo(tokens: readonly string[]): XargsParseResult {
// Options that take a value as the next token
const xargsOptsWithValue = new Set([
'-L',
'-n',
'-P',
'-s',
'-a',
'-E',
'-e',
'-d',
'-J',
'--max-args',
'--max-procs',
'--max-chars',
'--arg-file',
'--eof',
'--delimiter',
'--max-lines',
]);
let replacementToken: string | null = null;
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '--') {
return { childTokens: [...tokens.slice(i + 1)], replacementToken };
}
if (token.startsWith('-')) {
// Handle -I (replacement option)
if (token === '-I') {
// -I TOKEN - next arg is the token
replacementToken = (tokens[i + 1] as string | undefined) ?? '{}';
i += 2;
continue;
}
if (token.startsWith('-I') && token.length > 2) {
// -ITOKEN - token is attached
replacementToken = token.slice(2);
i++;
continue;
}
// Handle --replace option
// In GNU xargs, --replace takes an optional argument via =
// --replace alone uses {}, --replace=FOO uses FOO
if (token === '--replace') {
// --replace (defaults to {})
replacementToken = '{}';
i++;
continue;
}
if (token.startsWith('--replace=')) {
// --replace=TOKEN or --replace= (empty defaults to {})
const value = token.slice('--replace='.length);
replacementToken = value === '' ? '{}' : value;
i++;
continue;
}
// Handle -J (macOS xargs replacement, consumes value)
if (token === '-J') {
// -J just consumes its value, doesn't enable placeholder mode for analysis
i += 2;
continue;
}
if (xargsOptsWithValue.has(token)) {
i += 2;
} else if (token.startsWith('--') && token.includes('=')) {
i++;
} else if (
token.startsWith('-L') ||
token.startsWith('-n') ||
token.startsWith('-P') ||
token.startsWith('-s')
) {
// These can have attached values like -n5
i++;
} else {
// Unknown option, skip it
i++;
}
} else {
return { childTokens: [...tokens.slice(i)], replacementToken };
}
}
return { childTokens: [], replacementToken };
}
export function extractXargsChildCommand(tokens: readonly string[]): string[] {
return extractXargsChildCommandWithInfo(tokens).childTokens;
}

View File

@@ -0,0 +1,94 @@
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AuditLogEntry } from '../types.ts';
/**
* Sanitize session ID to prevent path traversal attacks.
* Returns null if the session ID is invalid.
* @internal Exported for testing
*/
export function sanitizeSessionIdForFilename(sessionId: string): string | null {
const raw = sessionId.trim();
if (!raw) {
return null;
}
// Replace any non-safe characters with underscores
let safe = raw.replace(/[^A-Za-z0-9_.-]+/g, '_');
// Strip leading/trailing special chars and limit length
safe = safe.replace(/^[._-]+|[._-]+$/g, '').slice(0, 128);
if (!safe || safe === '.' || safe === '..') {
return null;
}
return safe;
}
/**
* Write an audit log entry for a denied command.
* Logs are written to ~/.cc-safety-net/logs/<session_id>.jsonl
*/
export function writeAuditLog(
sessionId: string,
command: string,
segment: string,
reason: string,
cwd: string | null,
options: { homeDir?: string } = {},
): void {
const safeSessionId = sanitizeSessionIdForFilename(sessionId);
if (!safeSessionId) {
return;
}
const home = options.homeDir ?? homedir();
const logsDir = join(home, '.cc-safety-net', 'logs');
try {
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
const logFile = join(logsDir, `${safeSessionId}.jsonl`);
const entry: AuditLogEntry = {
ts: new Date().toISOString(),
command: redactSecrets(command).slice(0, 300),
segment: redactSecrets(segment).slice(0, 300),
reason,
cwd,
};
appendFileSync(logFile, `${JSON.stringify(entry)}\n`, 'utf-8');
} catch {
// Silently ignore errors (matches Python behavior)
}
}
/**
* Redact secrets from text to avoid leaking sensitive information in logs.
*/
export function redactSecrets(text: string): string {
let result = text;
// KEY=VALUE patterns for common secret-ish keys
result = result.replace(
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASS|KEY|CREDENTIALS)[A-Z0-9_]*)=([^\s]+)/gi,
'$1=<redacted>',
);
// Authorization headers
result = result.replace(/(['"]?\s*authorization\s*:\s*)([^'"]+)(['"]?)/gi, '$1<redacted>$3');
result = result.replace(/(authorization\s*:\s*)([^\s"']+)(\s+[^\s"']+)?/gi, '$1<redacted>');
// URL credentials: scheme://user:pass@host
result = result.replace(/(https?:\/\/)([^\s/:@]+):([^\s@]+)@/gi, '$1<redacted>:<redacted>@');
// Common GitHub token prefixes
result = result.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<redacted>');
return result;
}

View File

@@ -0,0 +1,222 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import {
COMMAND_PATTERN,
type Config,
MAX_REASON_LENGTH,
NAME_PATTERN,
type ValidationResult,
} from '../types.ts';
const DEFAULT_CONFIG: Config = {
version: 1,
rules: [],
};
export interface LoadConfigOptions {
/** Override user config directory (for testing) */
userConfigDir?: string;
}
export function loadConfig(cwd?: string, options?: LoadConfigOptions): Config {
const safeCwd = typeof cwd === 'string' ? cwd : process.cwd();
const userConfigDir = options?.userConfigDir ?? join(homedir(), '.cc-safety-net');
const userConfigPath = join(userConfigDir, 'config.json');
const projectConfigPath = join(safeCwd, '.safety-net.json');
const userConfig = loadSingleConfig(userConfigPath);
const projectConfig = loadSingleConfig(projectConfigPath);
return mergeConfigs(userConfig, projectConfig);
}
function loadSingleConfig(path: string): Config | null {
if (!existsSync(path)) {
return null;
}
try {
const content = readFileSync(path, 'utf-8');
if (!content.trim()) {
return null;
}
const parsed = JSON.parse(content) as unknown;
const result = validateConfig(parsed);
if (result.errors.length > 0) {
return null;
}
// Ensure rules array exists (may be undefined if not in input)
const cfg = parsed as Record<string, unknown>;
return {
version: cfg.version as number,
rules: (cfg.rules as Config['rules']) ?? [],
};
} catch {
return null;
}
}
function mergeConfigs(userConfig: Config | null, projectConfig: Config | null): Config {
if (!userConfig && !projectConfig) {
return DEFAULT_CONFIG;
}
if (!userConfig) {
return projectConfig ?? DEFAULT_CONFIG;
}
if (!projectConfig) {
return userConfig;
}
const projectRuleNames = new Set(projectConfig.rules.map((r) => r.name.toLowerCase()));
const mergedRules = [
...userConfig.rules.filter((r) => !projectRuleNames.has(r.name.toLowerCase())),
...projectConfig.rules,
];
return {
version: 1,
rules: mergedRules,
};
}
/** @internal Exported for testing */
export function validateConfig(config: unknown): ValidationResult {
const errors: string[] = [];
const ruleNames = new Set<string>();
if (!config || typeof config !== 'object') {
errors.push('Config must be an object');
return { errors, ruleNames };
}
const cfg = config as Record<string, unknown>;
if (cfg.version !== 1) {
errors.push('version must be 1');
}
if (cfg.rules !== undefined) {
if (!Array.isArray(cfg.rules)) {
errors.push('rules must be an array');
} else {
for (let i = 0; i < cfg.rules.length; i++) {
const rule = cfg.rules[i] as unknown;
const ruleErrors = validateRule(rule, i, ruleNames);
errors.push(...ruleErrors);
}
}
}
return { errors, ruleNames };
}
function validateRule(rule: unknown, index: number, ruleNames: Set<string>): string[] {
const errors: string[] = [];
const prefix = `rules[${index}]`;
if (!rule || typeof rule !== 'object') {
errors.push(`${prefix}: must be an object`);
return errors;
}
const r = rule as Record<string, unknown>;
if (typeof r.name !== 'string') {
errors.push(`${prefix}.name: required string`);
} else {
if (!NAME_PATTERN.test(r.name)) {
errors.push(
`${prefix}.name: must match pattern (letters, numbers, hyphens, underscores; max 64 chars)`,
);
}
const lowerName = r.name.toLowerCase();
if (ruleNames.has(lowerName)) {
errors.push(`${prefix}.name: duplicate rule name "${r.name}"`);
} else {
ruleNames.add(lowerName);
}
}
if (typeof r.command !== 'string') {
errors.push(`${prefix}.command: required string`);
} else if (!COMMAND_PATTERN.test(r.command)) {
errors.push(`${prefix}.command: must match pattern (letters, numbers, hyphens, underscores)`);
}
if (r.subcommand !== undefined) {
if (typeof r.subcommand !== 'string') {
errors.push(`${prefix}.subcommand: must be a string if provided`);
} else if (!COMMAND_PATTERN.test(r.subcommand)) {
errors.push(
`${prefix}.subcommand: must match pattern (letters, numbers, hyphens, underscores)`,
);
}
}
if (!Array.isArray(r.block_args)) {
errors.push(`${prefix}.block_args: required array`);
} else {
if (r.block_args.length === 0) {
errors.push(`${prefix}.block_args: must have at least one element`);
}
for (let i = 0; i < r.block_args.length; i++) {
const arg = r.block_args[i];
if (typeof arg !== 'string') {
errors.push(`${prefix}.block_args[${i}]: must be a string`);
} else if (arg === '') {
errors.push(`${prefix}.block_args[${i}]: must not be empty`);
}
}
}
if (typeof r.reason !== 'string') {
errors.push(`${prefix}.reason: required string`);
} else if (r.reason === '') {
errors.push(`${prefix}.reason: must not be empty`);
} else if (r.reason.length > MAX_REASON_LENGTH) {
errors.push(`${prefix}.reason: must be at most ${MAX_REASON_LENGTH} characters`);
}
return errors;
}
export function validateConfigFile(path: string): ValidationResult {
const errors: string[] = [];
const ruleNames = new Set<string>();
if (!existsSync(path)) {
errors.push(`File not found: ${path}`);
return { errors, ruleNames };
}
try {
const content = readFileSync(path, 'utf-8');
if (!content.trim()) {
errors.push('Config file is empty');
return { errors, ruleNames };
}
const parsed = JSON.parse(content) as unknown;
return validateConfig(parsed);
} catch (e) {
errors.push(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
return { errors, ruleNames };
}
}
export function getUserConfigPath(): string {
return join(homedir(), '.cc-safety-net', 'config.json');
}
export function getProjectConfigPath(cwd?: string): string {
return resolve(cwd ?? process.cwd(), '.safety-net.json');
}
export type { ValidationResult };

View File

@@ -0,0 +1,4 @@
export function envTruthy(name: string): boolean {
const value = process.env[name];
return value === '1' || value?.toLowerCase() === 'true';
}

View File

@@ -0,0 +1,36 @@
type RedactFn = (text: string) => string;
export interface FormatBlockedMessageInput {
reason: string;
command?: string;
segment?: string;
maxLen?: number;
redact?: RedactFn;
}
export function formatBlockedMessage(input: FormatBlockedMessageInput): string {
const { reason, command, segment } = input;
const maxLen = input.maxLen ?? 200;
const redact = input.redact ?? ((t: string) => t);
let message = `BLOCKED by Safety Net\n\nReason: ${reason}`;
if (command) {
const safeCommand = redact(command);
message += `\n\nCommand: ${excerpt(safeCommand, maxLen)}`;
}
if (segment && segment !== command) {
const safeSegment = redact(segment);
message += `\n\nSegment: ${excerpt(safeSegment, maxLen)}`;
}
message +=
'\n\nIf this operation is truly needed, ask the user for explicit permission and have them run the command manually.';
return message;
}
function excerpt(text: string, maxLen: number): string {
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
}

View File

@@ -0,0 +1,98 @@
import type { CustomRule } from '../types.ts';
import { extractShortOpts, getBasename } from './shell.ts';
export function checkCustomRules(tokens: string[], rules: CustomRule[]): string | null {
if (tokens.length === 0 || rules.length === 0) {
return null;
}
const command = getBasename(tokens[0] ?? '');
const subcommand = extractSubcommand(tokens);
const shortOpts = extractShortOpts(tokens);
for (const rule of rules) {
if (!matchesCommand(command, rule.command)) {
continue;
}
if (rule.subcommand && subcommand !== rule.subcommand) {
continue;
}
if (matchesBlockArgs(tokens, rule.block_args, shortOpts)) {
return `[${rule.name}] ${rule.reason}`;
}
}
return null;
}
function matchesCommand(command: string, ruleCommand: string): boolean {
return command === ruleCommand;
}
const OPTIONS_WITH_VALUES = new Set([
'-c',
'-C',
'--git-dir',
'--work-tree',
'--namespace',
'--config-env',
]);
function extractSubcommand(tokens: string[]): string | null {
let skipNext = false;
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (skipNext) {
skipNext = false;
continue;
}
if (token === '--') {
const nextToken = tokens[i + 1];
if (nextToken && !nextToken.startsWith('-')) {
return nextToken;
}
return null;
}
if (OPTIONS_WITH_VALUES.has(token)) {
skipNext = true;
continue;
}
if (token.startsWith('-')) {
for (const opt of OPTIONS_WITH_VALUES) {
if (token.startsWith(`${opt}=`)) {
break;
}
}
continue;
}
return token;
}
return null;
}
function matchesBlockArgs(tokens: string[], blockArgs: string[], shortOpts: Set<string>): boolean {
const blockArgsSet = new Set(blockArgs);
for (const token of tokens) {
if (blockArgsSet.has(token)) {
return true;
}
}
for (const opt of shortOpts) {
if (blockArgsSet.has(opt)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,354 @@
import { extractShortOpts, getBasename } from './shell.ts';
const REASON_CHECKOUT_DOUBLE_DASH =
"git checkout -- discards uncommitted changes permanently. Use 'git stash' first.";
const REASON_CHECKOUT_REF_PATH =
"git checkout <ref> -- <path> overwrites working tree with ref version. Use 'git stash' first.";
const REASON_CHECKOUT_PATHSPEC_FROM_FILE =
"git checkout --pathspec-from-file can overwrite multiple files. Use 'git stash' first.";
const REASON_CHECKOUT_AMBIGUOUS =
"git checkout with multiple positional args may overwrite files. Use 'git switch' for branches or 'git restore' for files.";
const REASON_RESTORE =
"git restore discards uncommitted changes. Use 'git stash' first, or use --staged to only unstage.";
const REASON_RESTORE_WORKTREE =
"git restore --worktree explicitly discards working tree changes. Use 'git stash' first.";
const REASON_RESET_HARD =
"git reset --hard destroys all uncommitted changes permanently. Use 'git stash' first.";
const REASON_RESET_MERGE = "git reset --merge can lose uncommitted changes. Use 'git stash' first.";
const REASON_CLEAN =
"git clean -f removes untracked files permanently. Use 'git clean -n' to preview first.";
const REASON_PUSH_FORCE =
'git push --force destroys remote history. Use --force-with-lease for safer force push.';
const REASON_BRANCH_DELETE =
'git branch -D force-deletes without merge check. Use -d for safe delete.';
const REASON_STASH_DROP =
"git stash drop permanently deletes stashed changes. Consider 'git stash list' first.";
const REASON_STASH_CLEAR = 'git stash clear deletes ALL stashed changes permanently.';
const REASON_WORKTREE_REMOVE_FORCE =
'git worktree remove --force can delete uncommitted changes. Remove --force flag.';
const GIT_GLOBAL_OPTS_WITH_VALUE = new Set([
'-c',
'-C',
'--git-dir',
'--work-tree',
'--namespace',
'--super-prefix',
'--config-env',
]);
const CHECKOUT_OPTS_WITH_VALUE = new Set([
'-b',
'-B',
'--orphan',
'--conflict',
'--pathspec-from-file',
'--unified',
]);
const CHECKOUT_OPTS_WITH_OPTIONAL_VALUE = new Set(['--recurse-submodules', '--track', '-t']);
const CHECKOUT_KNOWN_OPTS_NO_VALUE = new Set([
'-q',
'--quiet',
'-f',
'--force',
'-d',
'--detach',
'-m',
'--merge',
'-p',
'--patch',
'--ours',
'--theirs',
'--no-track',
'--overwrite-ignore',
'--no-overwrite-ignore',
'--ignore-other-worktrees',
'--progress',
'--no-progress',
]);
function splitAtDoubleDash(tokens: readonly string[]): {
index: number;
before: readonly string[];
after: readonly string[];
} {
const index = tokens.indexOf('--');
if (index === -1) {
return { index: -1, before: tokens, after: [] };
}
return {
index,
before: tokens.slice(0, index),
after: tokens.slice(index + 1),
};
}
export function analyzeGit(tokens: readonly string[]): string | null {
const { subcommand, rest } = extractGitSubcommandAndRest(tokens);
if (!subcommand) {
return null;
}
switch (subcommand.toLowerCase()) {
case 'checkout':
return analyzeGitCheckout(rest);
case 'restore':
return analyzeGitRestore(rest);
case 'reset':
return analyzeGitReset(rest);
case 'clean':
return analyzeGitClean(rest);
case 'push':
return analyzeGitPush(rest);
case 'branch':
return analyzeGitBranch(rest);
case 'stash':
return analyzeGitStash(rest);
case 'worktree':
return analyzeGitWorktree(rest);
default:
return null;
}
}
function extractGitSubcommandAndRest(tokens: readonly string[]): {
subcommand: string | null;
rest: string[];
} {
if (tokens.length === 0) {
return { subcommand: null, rest: [] };
}
const firstToken = tokens[0];
const command = firstToken ? getBasename(firstToken).toLowerCase() : null;
if (command !== 'git') {
return { subcommand: null, rest: [] };
}
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '--') {
const nextToken = tokens[i + 1];
if (nextToken && !nextToken.startsWith('-')) {
return { subcommand: nextToken, rest: tokens.slice(i + 2) };
}
return { subcommand: null, rest: tokens.slice(i + 1) };
}
if (token.startsWith('-')) {
if (GIT_GLOBAL_OPTS_WITH_VALUE.has(token)) {
i += 2;
} else if (token.startsWith('-c') && token.length > 2) {
i++;
} else if (token.startsWith('-C') && token.length > 2) {
i++;
} else {
i++;
}
} else {
return { subcommand: token, rest: tokens.slice(i + 1) };
}
}
return { subcommand: null, rest: [] };
}
function analyzeGitCheckout(tokens: readonly string[]): string | null {
const { index: doubleDashIdx, before: beforeDash } = splitAtDoubleDash(tokens);
for (const token of tokens) {
if (token === '-b' || token === '-B' || token === '--orphan') {
return null;
}
if (token === '--pathspec-from-file') {
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
}
if (token.startsWith('--pathspec-from-file=')) {
return REASON_CHECKOUT_PATHSPEC_FROM_FILE;
}
}
if (doubleDashIdx !== -1) {
const hasRefBeforeDash = beforeDash.some((t) => !t.startsWith('-'));
if (hasRefBeforeDash) {
return REASON_CHECKOUT_REF_PATH;
}
return REASON_CHECKOUT_DOUBLE_DASH;
}
const positionalArgs = getCheckoutPositionalArgs(tokens);
if (positionalArgs.length >= 2) {
return REASON_CHECKOUT_AMBIGUOUS;
}
return null;
}
function getCheckoutPositionalArgs(tokens: readonly string[]): string[] {
const positional: string[] = [];
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '--') {
break;
}
if (token.startsWith('-')) {
if (CHECKOUT_OPTS_WITH_VALUE.has(token)) {
i += 2;
} else if (token.startsWith('--') && token.includes('=')) {
i++;
} else if (CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)) {
const nextToken = tokens[i + 1];
if (
nextToken &&
!nextToken.startsWith('-') &&
(token === '--recurse-submodules' || token === '--track' || token === '-t')
) {
const validModes =
token === '--recurse-submodules' ? ['checkout', 'on-demand'] : ['direct', 'inherit'];
if (validModes.includes(nextToken)) {
i += 2;
} else {
i++;
}
} else {
i++;
}
} else if (
token.startsWith('--') &&
!CHECKOUT_KNOWN_OPTS_NO_VALUE.has(token) &&
!CHECKOUT_OPTS_WITH_VALUE.has(token) &&
!CHECKOUT_OPTS_WITH_OPTIONAL_VALUE.has(token)
) {
const nextToken = tokens[i + 1];
if (nextToken && !nextToken.startsWith('-')) {
i += 2;
} else {
i++;
}
} else {
i++;
}
} else {
positional.push(token);
i++;
}
}
return positional;
}
function analyzeGitRestore(tokens: readonly string[]): string | null {
let hasStaged = false;
for (const token of tokens) {
if (token === '--help' || token === '--version') {
return null;
}
// --worktree explicitly discards working tree changes, even with --staged
if (token === '--worktree' || token === '-W') {
return REASON_RESTORE_WORKTREE;
}
if (token === '--staged' || token === '-S') {
hasStaged = true;
}
}
// Only safe if --staged is present (and --worktree is not)
return hasStaged ? null : REASON_RESTORE;
}
function analyzeGitReset(tokens: readonly string[]): string | null {
for (const token of tokens) {
if (token === '--hard') {
return REASON_RESET_HARD;
}
if (token === '--merge') {
return REASON_RESET_MERGE;
}
}
return null;
}
function analyzeGitClean(tokens: readonly string[]): string | null {
for (const token of tokens) {
if (token === '-n' || token === '--dry-run') {
return null;
}
}
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
if (tokens.includes('--force') || shortOpts.has('-f')) {
return REASON_CLEAN;
}
return null;
}
function analyzeGitPush(tokens: readonly string[]): string | null {
let hasForceWithLease = false;
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
const hasForce = tokens.includes('--force') || shortOpts.has('-f');
for (const token of tokens) {
if (token === '--force-with-lease' || token.startsWith('--force-with-lease=')) {
hasForceWithLease = true;
}
}
if (hasForce && !hasForceWithLease) {
return REASON_PUSH_FORCE;
}
return null;
}
function analyzeGitBranch(tokens: readonly string[]): string | null {
const shortOpts = extractShortOpts(tokens.filter((t) => t !== '--'));
if (shortOpts.has('-D')) {
return REASON_BRANCH_DELETE;
}
return null;
}
function analyzeGitStash(tokens: readonly string[]): string | null {
for (const token of tokens) {
if (token === 'drop') {
return REASON_STASH_DROP;
}
if (token === 'clear') {
return REASON_STASH_CLEAR;
}
}
return null;
}
function analyzeGitWorktree(tokens: readonly string[]): string | null {
const hasRemove = tokens.includes('remove');
if (!hasRemove) return null;
const { before } = splitAtDoubleDash(tokens);
for (const token of before) {
if (token === '--force' || token === '-f') {
return REASON_WORKTREE_REMOVE_FORCE;
}
}
return null;
}
/** @internal Exported for testing */
export {
extractGitSubcommandAndRest as _extractGitSubcommandAndRest,
getCheckoutPositionalArgs as _getCheckoutPositionalArgs,
};

View File

@@ -0,0 +1,292 @@
import { realpathSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { normalize, resolve } from 'node:path';
import { hasRecursiveForceFlags } from './analyze/rm-flags.ts';
const REASON_RM_RF =
'rm -rf outside cwd is blocked. Use explicit paths within the current directory, or delete manually.';
const REASON_RM_RF_ROOT_HOME =
'rm -rf targeting root or home directory is extremely dangerous and always blocked.';
export interface AnalyzeRmOptions {
cwd?: string;
originalCwd?: string;
paranoid?: boolean;
allowTmpdirVar?: boolean;
tmpdirOverridden?: boolean;
}
interface RmContext {
readonly anchoredCwd: string | null;
readonly resolvedCwd: string | null;
readonly paranoid: boolean;
readonly trustTmpdirVar: boolean;
readonly homeDir: string;
}
type TargetClassification =
| { kind: 'root_or_home_target' }
| { kind: 'cwd_self_target' }
| { kind: 'temp_target' }
| { kind: 'within_anchored_cwd' }
| { kind: 'outside_anchored_cwd' };
export function analyzeRm(tokens: string[], options: AnalyzeRmOptions = {}): string | null {
const {
cwd,
originalCwd,
paranoid = false,
allowTmpdirVar = true,
tmpdirOverridden = false,
} = options;
const anchoredCwd = originalCwd ?? cwd ?? null;
const resolvedCwd = cwd ?? null;
const trustTmpdirVar = allowTmpdirVar && !tmpdirOverridden;
const ctx: RmContext = {
anchoredCwd,
resolvedCwd,
paranoid,
trustTmpdirVar,
homeDir: getHomeDirForRmPolicy(),
};
if (!hasRecursiveForceFlags(tokens)) {
return null;
}
const targets = extractTargets(tokens);
for (const target of targets) {
const classification = classifyTarget(target, ctx);
const reason = reasonForClassification(classification, ctx);
if (reason) {
return reason;
}
}
return null;
}
function extractTargets(tokens: readonly string[]): string[] {
const targets: string[] = [];
let pastDoubleDash = false;
for (let i = 1; i < tokens.length; i++) {
const token = tokens[i];
if (!token) continue;
if (token === '--') {
pastDoubleDash = true;
continue;
}
if (pastDoubleDash) {
targets.push(token);
continue;
}
if (!token.startsWith('-')) {
targets.push(token);
}
}
return targets;
}
function classifyTarget(target: string, ctx: RmContext): TargetClassification {
if (isDangerousRootOrHomeTarget(target)) {
return { kind: 'root_or_home_target' };
}
const anchoredCwd = ctx.anchoredCwd;
if (anchoredCwd) {
if (isCwdSelfTarget(target, anchoredCwd)) {
return { kind: 'cwd_self_target' };
}
}
if (isTempTarget(target, ctx.trustTmpdirVar)) {
return { kind: 'temp_target' };
}
if (anchoredCwd) {
if (isCwdHomeForRmPolicy(anchoredCwd, ctx.homeDir)) {
return { kind: 'root_or_home_target' };
}
if (isTargetWithinCwd(target, anchoredCwd, ctx.resolvedCwd ?? anchoredCwd)) {
return { kind: 'within_anchored_cwd' };
}
}
return { kind: 'outside_anchored_cwd' };
}
function reasonForClassification(
classification: TargetClassification,
ctx: RmContext,
): string | null {
switch (classification.kind) {
case 'root_or_home_target':
return REASON_RM_RF_ROOT_HOME;
case 'cwd_self_target':
return REASON_RM_RF;
case 'temp_target':
return null;
case 'within_anchored_cwd':
if (ctx.paranoid) {
return `${REASON_RM_RF} (SAFETY_NET_PARANOID_RM enabled)`;
}
return null;
case 'outside_anchored_cwd':
return REASON_RM_RF;
}
}
function isDangerousRootOrHomeTarget(path: string): boolean {
const normalized = path.trim();
if (normalized === '/' || normalized === '/*') {
return true;
}
if (normalized === '~' || normalized === '~/' || normalized.startsWith('~/')) {
if (normalized === '~' || normalized === '~/' || normalized === '~/*') {
return true;
}
}
if (normalized === '$HOME' || normalized === '$HOME/' || normalized === '$HOME/*') {
return true;
}
if (normalized === '${HOME}' || normalized === '${HOME}/' || normalized === '${HOME}/*') {
return true;
}
return false;
}
function isTempTarget(path: string, allowTmpdirVar: boolean): boolean {
const normalized = path.trim();
if (normalized.includes('..')) {
return false;
}
if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
return true;
}
if (normalized === '/var/tmp' || normalized.startsWith('/var/tmp/')) {
return true;
}
const systemTmpdir = tmpdir();
if (normalized.startsWith(`${systemTmpdir}/`) || normalized === systemTmpdir) {
return true;
}
if (allowTmpdirVar) {
if (normalized === '$TMPDIR' || normalized.startsWith('$TMPDIR/')) {
return true;
}
if (normalized === '${TMPDIR}' || normalized.startsWith('${TMPDIR}/')) {
return true;
}
}
return false;
}
function getHomeDirForRmPolicy(): string {
return process.env.HOME ?? homedir();
}
function isCwdHomeForRmPolicy(cwd: string, homeDir: string): boolean {
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(homeDir);
return normalizedCwd === normalizedHome;
} catch {
return false;
}
}
function isCwdSelfTarget(target: string, cwd: string): boolean {
if (target === '.' || target === './') {
return true;
}
try {
const resolved = resolve(cwd, target);
const realCwd = realpathSync(cwd);
const realResolved = realpathSync(resolved);
return realResolved === realCwd;
} catch {
// realpathSync throws if the path doesn't exist; fall back to a
// normalize/resolve based comparison.
try {
const resolved = resolve(cwd, target);
const normalizedCwd = normalize(cwd);
return resolved === normalizedCwd;
} catch {
return false;
}
}
}
function isTargetWithinCwd(target: string, originalCwd: string, effectiveCwd?: string): boolean {
const resolveCwd = effectiveCwd ?? originalCwd;
if (target.startsWith('~') || target.startsWith('$HOME') || target.startsWith('${HOME}')) {
return false;
}
if (target.includes('$') || target.includes('`')) {
return false;
}
if (target.startsWith('/')) {
try {
const normalizedTarget = normalize(target);
const normalizedCwd = `${normalize(originalCwd)}/`;
return normalizedTarget.startsWith(normalizedCwd);
} catch {
return false;
}
}
if (target.startsWith('./') || !target.includes('/')) {
try {
const resolved = resolve(resolveCwd, target);
const normalizedOriginalCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedOriginalCwd}/`) || resolved === normalizedOriginalCwd;
} catch {
return false;
}
}
if (target.startsWith('../')) {
return false;
}
try {
const resolved = resolve(resolveCwd, target);
const normalizedCwd = normalize(originalCwd);
return resolved.startsWith(`${normalizedCwd}/`) || resolved === normalizedCwd;
} catch {
return false;
}
}
export function isHomeDirectory(cwd: string): boolean {
const home = process.env.HOME ?? homedir();
try {
const normalizedCwd = normalize(cwd);
const normalizedHome = normalize(home);
return normalizedCwd === normalizedHome;
} catch {
return false;
}
}

View File

@@ -0,0 +1,442 @@
import { type ParseEntry, parse } from 'shell-quote';
import { MAX_STRIP_ITERATIONS, SHELL_OPERATORS } from '../types.ts';
// Proxy that preserves variable references as $VAR strings instead of expanding them
const ENV_PROXY = new Proxy(
{},
{
get: (_, name) => `$${String(name)}`,
},
);
export function splitShellCommands(command: string): string[][] {
if (hasUnclosedQuotes(command)) {
return [[command]];
}
const normalizedCommand = command.replace(/\n/g, ' ; ');
const tokens = parse(normalizedCommand, ENV_PROXY);
const segments: string[][] = [];
let current: string[] = [];
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token === undefined) {
i++;
continue;
}
if (isOperator(token)) {
if (current.length > 0) {
segments.push(current);
current = [];
}
i++;
continue;
}
if (typeof token !== 'string') {
i++;
continue;
}
// Handle string tokens
const nextToken = tokens[i + 1];
if (token === '$' && nextToken && isParenOpen(nextToken)) {
if (current.length > 0) {
segments.push(current);
current = [];
}
const { innerSegments, endIndex } = extractCommandSubstitution(tokens, i + 2);
for (const seg of innerSegments) {
segments.push(seg);
}
i = endIndex + 1;
continue;
}
const backtickSegments = extractBacktickSubstitutions(token);
if (backtickSegments.length > 0) {
for (const seg of backtickSegments) {
segments.push(seg);
}
}
current.push(token);
i++;
}
if (current.length > 0) {
segments.push(current);
}
return segments;
}
function extractBacktickSubstitutions(token: string): string[][] {
const segments: string[][] = [];
let i = 0;
while (i < token.length) {
const backtickStart = token.indexOf('`', i);
if (backtickStart === -1) break;
const backtickEnd = token.indexOf('`', backtickStart + 1);
if (backtickEnd === -1) break;
const innerCommand = token.slice(backtickStart + 1, backtickEnd);
if (innerCommand.trim()) {
const innerSegments = splitShellCommands(innerCommand);
for (const seg of innerSegments) {
segments.push(seg);
}
}
i = backtickEnd + 1;
}
return segments;
}
function isParenOpen(token: ParseEntry | undefined): boolean {
return typeof token === 'object' && token !== null && 'op' in token && token.op === '(';
}
function isParenClose(token: ParseEntry | undefined): boolean {
return typeof token === 'object' && token !== null && 'op' in token && token.op === ')';
}
function extractCommandSubstitution(
tokens: ParseEntry[],
startIndex: number,
): { innerSegments: string[][]; endIndex: number } {
const innerSegments: string[][] = [];
let currentSegment: string[] = [];
let depth = 1;
let i = startIndex;
while (i < tokens.length && depth > 0) {
const token = tokens[i];
if (isParenOpen(token)) {
depth++;
i++;
continue;
}
if (isParenClose(token)) {
depth--;
if (depth === 0) break;
i++;
continue;
}
if (depth === 1 && token && isOperator(token)) {
if (currentSegment.length > 0) {
innerSegments.push(currentSegment);
currentSegment = [];
}
i++;
continue;
}
if (typeof token === 'string') {
currentSegment.push(token);
}
i++;
}
if (currentSegment.length > 0) {
innerSegments.push(currentSegment);
}
return { innerSegments, endIndex: i };
}
function hasUnclosedQuotes(command: string): boolean {
let inSingle = false;
let inDouble = false;
let escaped = false;
for (const char of command) {
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === "'" && !inDouble) {
inSingle = !inSingle;
} else if (char === '"' && !inSingle) {
inDouble = !inDouble;
}
}
return inSingle || inDouble;
}
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
function parseEnvAssignment(token: string): { name: string; value: string } | null {
if (!ENV_ASSIGNMENT_RE.test(token)) {
return null;
}
const eqIdx = token.indexOf('=');
if (eqIdx < 0) {
return null;
}
return { name: token.slice(0, eqIdx), value: token.slice(eqIdx + 1) };
}
export interface EnvStrippingResult {
tokens: string[];
envAssignments: Map<string, string>;
}
export function stripEnvAssignmentsWithInfo(tokens: string[]): EnvStrippingResult {
const envAssignments = new Map<string, string>();
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (!token) {
break;
}
const assignment = parseEnvAssignment(token);
if (!assignment) {
break;
}
envAssignments.set(assignment.name, assignment.value);
i++;
}
return { tokens: tokens.slice(i), envAssignments };
}
export interface WrapperStrippingResult {
tokens: string[];
envAssignments: Map<string, string>;
}
export function stripWrappers(tokens: string[]): string[] {
return stripWrappersWithInfo(tokens).tokens;
}
export function stripWrappersWithInfo(tokens: string[]): WrapperStrippingResult {
let result = [...tokens];
const allEnvAssignments = new Map<string, string>();
for (let iteration = 0; iteration < MAX_STRIP_ITERATIONS; iteration++) {
const before = result.join(' ');
const { tokens: strippedTokens, envAssignments } = stripEnvAssignmentsWithInfo(result);
for (const [k, v] of envAssignments) {
allEnvAssignments.set(k, v);
}
result = strippedTokens;
if (result.length === 0) break;
while (
result.length > 0 &&
result[0]?.includes('=') &&
!ENV_ASSIGNMENT_RE.test(result[0] ?? '')
) {
// Conservative parsing: only strict NAME=value is treated as an env assignment.
// Other leading tokens that contain '=' (e.g. NAME+=value) are dropped to reach
// the actual executable token.
result = result.slice(1);
}
if (result.length === 0) break;
const head = result[0]?.toLowerCase();
// Guard: unknown wrapper type, exit loop
if (head !== 'sudo' && head !== 'env' && head !== 'command') {
break;
}
if (head === 'sudo') {
result = stripSudo(result);
}
if (head === 'env') {
const envResult = stripEnvWithInfo(result);
result = envResult.tokens;
for (const [k, v] of envResult.envAssignments) {
allEnvAssignments.set(k, v);
}
}
if (head === 'command') {
result = stripCommand(result);
}
if (result.join(' ') === before) break;
}
const { tokens: finalTokens, envAssignments: finalAssignments } =
stripEnvAssignmentsWithInfo(result);
for (const [k, v] of finalAssignments) {
allEnvAssignments.set(k, v);
}
return { tokens: finalTokens, envAssignments: allEnvAssignments };
}
const SUDO_OPTS_WITH_VALUE = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U']);
function stripSudo(tokens: string[]): string[] {
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '--') {
return tokens.slice(i + 1);
}
// Guard: not an option, exit loop
if (!token.startsWith('-')) {
break;
}
if (SUDO_OPTS_WITH_VALUE.has(token)) {
i += 2;
continue;
}
i++;
}
return tokens.slice(i);
}
const ENV_OPTS_NO_VALUE = new Set(['-i', '-0', '--null']);
const ENV_OPTS_WITH_VALUE = new Set([
'-u',
'--unset',
'-C',
'--chdir',
'-S',
'--split-string',
'-P',
]);
function stripEnvWithInfo(tokens: string[]): EnvStrippingResult {
const envAssignments = new Map<string, string>();
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '--') {
return { tokens: tokens.slice(i + 1), envAssignments };
}
if (ENV_OPTS_NO_VALUE.has(token)) {
i++;
continue;
}
if (ENV_OPTS_WITH_VALUE.has(token)) {
i += 2;
continue;
}
if (token.startsWith('-u=') || token.startsWith('--unset=')) {
i++;
continue;
}
if (token.startsWith('-C=') || token.startsWith('--chdir=')) {
i++;
continue;
}
if (token.startsWith('-P')) {
i++;
continue;
}
if (token.startsWith('-')) {
i++;
continue;
}
// Not an option - try to parse as env assignment
const assignment = parseEnvAssignment(token);
if (!assignment) {
break;
}
envAssignments.set(assignment.name, assignment.value);
i++;
}
return { tokens: tokens.slice(i), envAssignments };
}
function stripCommand(tokens: string[]): string[] {
let i = 1;
while (i < tokens.length) {
const token = tokens[i];
if (!token) break;
if (token === '-p' || token === '-v' || token === '-V') {
i++;
continue;
}
if (token === '--') {
return tokens.slice(i + 1);
}
// Check for combined short opts like -pv
if (token.startsWith('-') && !token.startsWith('--') && token.length > 1) {
const chars = token.slice(1);
if (!/^[pvV]+$/.test(chars)) {
break;
}
i++;
continue;
}
break;
}
return tokens.slice(i);
}
export function extractShortOpts(tokens: string[]): Set<string> {
const opts = new Set<string>();
let pastDoubleDash = false;
for (const token of tokens) {
if (token === '--') {
pastDoubleDash = true;
continue;
}
if (pastDoubleDash) continue;
if (token.startsWith('-') && !token.startsWith('--') && token.length > 1) {
for (let i = 1; i < token.length; i++) {
const char = token[i];
if (!char || !/[a-zA-Z]/.test(char)) {
break;
}
opts.add(`-${char}`);
}
}
}
return opts;
}
export function normalizeCommandToken(token: string): string {
return getBasename(token).toLowerCase();
}
export function getBasename(token: string): string {
return token.includes('/') ? (token.split('/').pop() ?? token) : token;
}
function isOperator(token: ParseEntry): boolean {
return (
typeof token === 'object' &&
token !== null &&
'op' in token &&
SHELL_OPERATORS.has(token.op as string)
);
}

View File

@@ -0,0 +1,27 @@
import { SET_CUSTOM_RULES_TEMPLATE } from './templates/set-custom-rules.ts';
import { VERIFY_CUSTOM_RULES_TEMPLATE } from './templates/verify-custom-rules.ts';
import type { BuiltinCommandName, BuiltinCommands, CommandDefinition } from './types.ts';
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, CommandDefinition> = {
'set-custom-rules': {
description: 'Set custom rules for Safety Net',
template: SET_CUSTOM_RULES_TEMPLATE,
},
'verify-custom-rules': {
description: 'Verify custom rules for Safety Net',
template: VERIFY_CUSTOM_RULES_TEMPLATE,
},
};
export function loadBuiltinCommands(disabledCommands?: BuiltinCommandName[]): BuiltinCommands {
const disabled = new Set(disabledCommands ?? []);
const commands: BuiltinCommands = {};
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
commands[name] = definition;
}
}
return commands;
}

View File

@@ -0,0 +1,2 @@
export * from './commands.ts';
export * from './types.ts';

View File

@@ -0,0 +1,67 @@
export const SET_CUSTOM_RULES_TEMPLATE = `You are helping the user configure custom blocking rules for claude-code-safety-net.
## Context
### Schema Documentation
!\`npx -y cc-safety-net --custom-rules-doc\`
## Your Task
Follow this flow exactly:
### Step 1: Ask for Scope
Ask: **Which scope would you like to configure?**
- **User** (\`~/.cc-safety-net/config.json\`) - applies to all your projects
- **Project** (\`.safety-net.json\`) - applies only to this project
### Step 2: Show Examples and Ask for Rules
Show examples in natural language:
- "Block \`git add -A\` and \`git add .\` to prevent blanket staging"
- "Block \`npm install -g\` to prevent global package installs"
- "Block \`docker system prune\` to prevent accidental cleanup"
Ask the user to describe rules in natural language. They can list multiple.
### Step 3: Generate JSON Config
Parse user input and generate valid schema JSON using the schema documentation above.
### Step 4: Show Config and Confirm
Display the generated JSON and ask:
- "Does this look correct?"
- "Would you like to modify anything?"
### Step 5: Check and Handle Existing Config
1. Check existing User Config with \`cat ~/.cc-safety-net/config.json 2>/dev/null || echo "No user config found"\`
2. Check existing Project Config with \`cat .safety-net.json 2>/dev/null || echo "No project config found"\`
If the chosen scope already has a config:
Show the existing config to the user.
Ask: **Merge** (add new rules, duplicates use new version) or **Replace**?
### Step 6: Write and Validate
Write the config to the chosen scope, then validate with \`npx -y cc-safety-net --verify-config\`.
If validation errors:
- Show specific errors
- Offer to fix with your best suggestion
- Confirm before proceeding
### Step 7: Confirm Success
Tell the user:
1. Config saved to [path]
2. **Changes take effect immediately** - no restart needed
3. Summary of rules added
## Important Notes
- Custom rules can only ADD restrictions, not bypass built-in protections
- Rule names must be unique (case-insensitive)
- Invalid config → entire config ignored, only built-in rules apply`;

View File

@@ -0,0 +1,12 @@
export const VERIFY_CUSTOM_RULES_TEMPLATE = `You are helping the user verify the custom rules config file.
## Your Task
Run \`npx -y cc-safety-net --verify-config\` to check current validation status
If the config has validation errors:
1. Show the specific validation errors
2. Run \`npx -y cc-safety-net --custom-rules-doc\` to read the schema documentation
3. Offer to fix them with your best suggestion
4. Ask for confirmation before proceeding
5. After fixing, run \`npx -y cc-safety-net --verify-config\` to verify again`;

View File

@@ -0,0 +1,12 @@
export type BuiltinCommandName = 'set-custom-rules' | 'verify-custom-rules';
// export interface BuiltinCommandConfig {
// disabled_commands?: BuiltinCommandName[];
// }
export interface CommandDefinition {
description?: string;
template: string;
}
export type BuiltinCommands = Record<string, CommandDefinition>;

View File

@@ -0,0 +1,47 @@
import type { Plugin } from '@opencode-ai/plugin';
import { analyzeCommand, loadConfig } from './core/analyze.ts';
import { envTruthy } from './core/env.ts';
import { formatBlockedMessage } from './core/format.ts';
import { loadBuiltinCommands } from './features/builtin-commands/index.ts';
export const SafetyNetPlugin: Plugin = async ({ directory }) => {
const safetyNetConfig = loadConfig(directory);
const strict = envTruthy('SAFETY_NET_STRICT');
const paranoidAll = envTruthy('SAFETY_NET_PARANOID');
const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM');
const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS');
return {
config: async (opencodeConfig: Record<string, unknown>) => {
const builtinCommands = loadBuiltinCommands();
const existingCommands = (opencodeConfig.command as Record<string, unknown>) ?? {};
opencodeConfig.command = {
...builtinCommands,
...existingCommands,
};
},
'tool.execute.before': async (input, output) => {
if (input.tool === 'bash') {
const command = output.args.command;
const result = analyzeCommand(command, {
cwd: directory,
config: safetyNetConfig,
strict,
paranoidRm,
paranoidInterpreters,
});
if (result) {
const message = formatBlockedMessage({
reason: result.reason,
command,
segment: result.segment,
});
throw new Error(message);
}
}
},
};
};

View File

@@ -0,0 +1,148 @@
/**
* Shared types for the safety-net plugin.
*/
/** Custom rule definition from .safety-net.json */
export interface CustomRule {
/** Unique identifier for the rule */
name: string;
/** Base command to match (e.g., "git", "npm") */
command: string;
/** Optional subcommand to match (e.g., "add", "install") */
subcommand?: string;
/** Arguments that trigger the block */
block_args: string[];
/** Message shown when blocked */
reason: string;
}
/** Configuration loaded from .safety-net.json */
export interface Config {
/** Schema version (must be 1) */
version: number;
/** Custom blocking rules */
rules: CustomRule[];
}
/** Result of config validation */
export interface ValidationResult {
/** List of validation error messages */
errors: string[];
/** Set of rule names found (for duplicate detection) */
ruleNames: Set<string>;
}
/** Result of command analysis */
export interface AnalyzeResult {
/** The reason the command was blocked */
reason: string;
/** The specific segment that triggered the block */
segment: string;
}
/** Claude Code hook input format */
export interface HookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
permission_mode?: string;
hook_event_name: string;
tool_name: string;
tool_input: {
command: string;
description?: string;
};
tool_use_id?: string;
}
/** Claude Code hook output format */
export interface HookOutput {
hookSpecificOutput: {
hookEventName: string;
permissionDecision: 'allow' | 'deny';
permissionDecisionReason?: string;
};
}
/** Gemini CLI hook input format */
export interface GeminiHookInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name: string;
timestamp?: string;
tool_name?: string;
tool_input?: {
command?: string;
[key: string]: unknown;
};
}
/** Gemini CLI hook output format */
export interface GeminiHookOutput {
decision: 'deny';
reason: string;
systemMessage: string;
continue?: boolean;
stopReason?: string;
suppressOutput?: boolean;
}
/** Options for command analysis */
export interface AnalyzeOptions {
/** Current working directory */
cwd?: string;
/** Effective cwd after cd commands (null = unknown, undefined = use cwd) */
effectiveCwd?: string | null;
/** Loaded configuration */
config?: Config;
/** Fail-closed on unparseable commands */
strict?: boolean;
/** Block non-temp rm -rf even within cwd */
paranoidRm?: boolean;
/** Block interpreter one-liners */
paranoidInterpreters?: boolean;
/** Allow $TMPDIR paths (false when TMPDIR is overridden to non-temp) */
allowTmpdirVar?: boolean;
}
/** Audit log entry */
export interface AuditLogEntry {
ts: string;
command: string;
segment: string;
reason: string;
cwd?: string | null;
}
/** Constants */
export const MAX_RECURSION_DEPTH = 10;
export const MAX_STRIP_ITERATIONS = 20;
export const NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
export const COMMAND_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
export const MAX_REASON_LENGTH = 256;
/** Shell operators that split commands */
export const SHELL_OPERATORS = new Set(['&&', '||', '|&', '|', '&', ';', '\n']);
/** Shell wrappers that need recursive analysis */
export const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'ksh', 'dash', 'fish', 'csh', 'tcsh']);
/** Interpreters that can execute code */
export const INTERPRETERS = new Set(['python', 'python3', 'python2', 'node', 'ruby', 'perl']);
/** Dangerous commands to detect in interpreter code */
export const DANGEROUS_PATTERNS = [
/\brm\s+.*-[rR].*-f\b/,
/\brm\s+.*-f.*-[rR]\b/,
/\brm\s+-rf\b/,
/\brm\s+-fr\b/,
/\bgit\s+reset\s+--hard\b/,
/\bgit\s+checkout\s+--\b/,
/\bgit\s+clean\s+-f\b/,
/\bfind\b.*\s-delete\b/,
];
export const PARANOID_INTERPRETERS_SUFFIX =
'\n\n(Paranoid mode: interpreter one-liners are blocked.)';

View File

@@ -0,0 +1,230 @@
import { describe, expect, test } from 'bun:test';
import { homedir } from 'node:os';
import { analyzeCommand } from '../src/core/analyze.ts';
import type { Config } from '../src/types.ts';
const EMPTY_CONFIG: Config = { version: 1, rules: [] };
describe('analyzeCommand (coverage)', () => {
test('unclosed-quote cd segment handled', () => {
// Ensures cwd-tracking fallback runs for unparseable cd segments.
expect(
analyzeCommand('cd "unterminated', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('empty head token returns null', () => {
expect(
analyzeCommand('""', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('rm -rf in home cwd is blocked with dedicated message', () => {
const result = analyzeCommand('rm -rf build', {
cwd: homedir(),
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('rm -rf in home directory');
});
test('rm without -rf in home cwd is not blocked by home cwd guard', () => {
expect(
analyzeCommand('rm -f file.txt', {
cwd: homedir(),
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('custom rules can block rm after builtin allow', () => {
const config: Config = {
version: 1,
rules: [
{
name: 'block-rm-rf',
command: 'rm',
block_args: ['-rf'],
reason: 'No rm -rf.',
},
],
};
const result = analyzeCommand('rm -rf /tmp/test-dir', {
cwd: '/tmp',
config,
});
expect(result?.reason).toContain('[block-rm-rf] No rm -rf.');
});
test('custom rules can block find after builtin allow', () => {
const config: Config = {
version: 1,
rules: [
{
name: 'block-find-print',
command: 'find',
block_args: ['-print'],
reason: 'Avoid find -print in tests.',
},
],
};
const result = analyzeCommand('find . -print', { cwd: '/tmp', config });
expect(result?.reason).toContain('[block-find-print] Avoid find -print in tests.');
});
test('fallback scan catches embedded rm', () => {
const result = analyzeCommand('tool rm -rf /', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('extremely dangerous');
});
test('fallback scan ignores embedded rm when analyzeRm allows it', () => {
expect(
analyzeCommand('tool rm -rf /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('fallback scan catches embedded git', () => {
const result = analyzeCommand('tool git reset --hard', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('fallback scan ignores embedded git when safe', () => {
expect(
analyzeCommand('tool git status', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('fallback scan catches embedded find', () => {
const result = analyzeCommand('tool find . -delete', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('find -delete');
});
test('fallback scan ignores embedded find when safe', () => {
expect(
analyzeCommand('tool find . -print', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('TMPDIR override to a temp dir keeps $TMPDIR allowed', () => {
const result = analyzeCommand('TMPDIR=/tmp rm -rf $TMPDIR/test-dir', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('xargs child git command is analyzed', () => {
const result = analyzeCommand('xargs git reset --hard', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('xargs child git command can be safe', () => {
expect(
analyzeCommand('xargs git status', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
describe('parallel parsing/analysis branches', () => {
test('parallel bash -c with placeholder and no args analyzes template', () => {
const result = analyzeCommand("parallel bash -c 'echo {}'", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel bash -c with placeholder outside script is blocked', () => {
const result = analyzeCommand("parallel bash -c 'echo hi' {} ::: a", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel bash -c without script but with args is blocked', () => {
const result = analyzeCommand("parallel bash -c ::: 'echo hi'", {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel bash -c without script or args is allowed', () => {
expect(
analyzeCommand('parallel bash -c', {
cwd: '/tmp',
config: EMPTY_CONFIG,
}),
).toBeNull();
});
test('parallel bash with placeholder but missing -c arg is blocked', () => {
const result = analyzeCommand('parallel bash {} -c', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('parallel with shell -c');
});
test('parallel rm -rf with explicit temp arg is allowed', () => {
const result = analyzeCommand('parallel rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel git tokens are analyzed', () => {
const result = analyzeCommand('parallel git reset --hard :::', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result?.reason).toContain('git reset --hard');
});
test('parallel with -- separator parses template', () => {
const result = analyzeCommand('parallel -- rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
test('parallel -j option consumes its value', () => {
const result = analyzeCommand('parallel -j 4 rm -rf ::: /tmp/a', {
cwd: '/tmp',
config: EMPTY_CONFIG,
});
expect(result).toBeNull();
});
});
});

Some files were not shown because too many files have changed in this diff Show More