Reorganize: Move all skills to skills/ folder
- Created skills/ directory - Moved 272 skills to skills/ subfolder - Kept agents/ at root level - Kept installation scripts and docs at root level Repository structure: - skills/ - All 272 skills from skills.sh - agents/ - Agent definitions - *.sh, *.ps1 - Installation scripts - README.md, etc. - Documentation Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
220
skills/plugins/claude-code-safety-net/AGENTS.md
Normal file
220
skills/plugins/claude-code-safety-net/AGENTS.md
Normal 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`.
|
||||
95
skills/plugins/claude-code-safety-net/CLAUDE.md
Normal file
95
skills/plugins/claude-code-safety-net/CLAUDE.md
Normal 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`.
|
||||
422
skills/plugins/claude-code-safety-net/CONTRIBUTING.md
Normal file
422
skills/plugins/claude-code-safety-net/CONTRIBUTING.md
Normal 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.
|
||||
21
skills/plugins/claude-code-safety-net/LICENSE
Normal file
21
skills/plugins/claude-code-safety-net/LICENSE
Normal 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.
|
||||
587
skills/plugins/claude-code-safety-net/README.md
Normal file
587
skills/plugins/claude-code-safety-net/README.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Claude Code Safety Net
|
||||
|
||||
[](https://github.com/kenryu42/claude-code-safety-net/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/github/kenryu42/claude-code-safety-net)
|
||||
[](https://github.com/kenryu42/claude-code-safety-net)
|
||||
[](#claude-code-installation)
|
||||
[](#opencode-installation)
|
||||
[](#gemini-cli-installation)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](./.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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')"
|
||||
@@ -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
|
||||
44
skills/plugins/claude-code-safety-net/biome.json
Normal file
44
skills/plugins/claude-code-safety-net/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
264
skills/plugins/claude-code-safety-net/bun.lock
Normal file
264
skills/plugins/claude-code-safety-net/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
3
skills/plugins/claude-code-safety-net/bunfig.toml
Normal file
3
skills/plugins/claude-code-safety-net/bunfig.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[test]
|
||||
|
||||
coverageThreshold = 0.9
|
||||
8
skills/plugins/claude-code-safety-net/codecov.yml
Normal file
8
skills/plugins/claude-code-safety-net/codecov.yml
Normal 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
|
||||
@@ -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
|
||||
172
skills/plugins/claude-code-safety-net/commands/set-statusline.md
Normal file
172
skills/plugins/claude-code-safety-net/commands/set-statusline.md
Normal 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
|
||||
@@ -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
|
||||
2
skills/plugins/claude-code-safety-net/dist/bin/cc-safety-net.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/bin/cc-safety-net.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
2796
skills/plugins/claude-code-safety-net/dist/bin/cc-safety-net.js
vendored
Executable file
2796
skills/plugins/claude-code-safety-net/dist/bin/cc-safety-net.js
vendored
Executable file
File diff suppressed because it is too large
Load Diff
1
skills/plugins/claude-code-safety-net/dist/bin/claude-code.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/bin/claude-code.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function runClaudeCodeHook(): Promise<void>;
|
||||
1
skills/plugins/claude-code-safety-net/dist/bin/custom-rules-doc.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/bin/custom-rules-doc.d.ts
vendored
Normal 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";
|
||||
1
skills/plugins/claude-code-safety-net/dist/bin/gemini-cli.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/bin/gemini-cli.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function runGeminiCLIHook(): Promise<void>;
|
||||
2
skills/plugins/claude-code-safety-net/dist/bin/help.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/bin/help.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export declare function printHelp(): void;
|
||||
export declare function printVersion(): void;
|
||||
1
skills/plugins/claude-code-safety-net/dist/bin/statusline.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/bin/statusline.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function printStatusline(): Promise<void>;
|
||||
12
skills/plugins/claude-code-safety-net/dist/bin/verify-config.d.ts
vendored
Normal file
12
skills/plugins/claude-code-safety-net/dist/bin/verify-config.d.ts
vendored
Normal 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;
|
||||
21
skills/plugins/claude-code-safety-net/dist/core/analyze.d.ts
vendored
Normal file
21
skills/plugins/claude-code-safety-net/dist/core/analyze.d.ts
vendored
Normal 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 };
|
||||
5
skills/plugins/claude-code-safety-net/dist/core/analyze/analyze-command.d.ts
vendored
Normal file
5
skills/plugins/claude-code-safety-net/dist/core/analyze/analyze-command.d.ts
vendored
Normal 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;
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/analyze/constants.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/analyze/constants.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare const DISPLAY_COMMANDS: ReadonlySet<string>;
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/analyze/dangerous-text.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/analyze/dangerous-text.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function dangerousInText(text: string): string | null;
|
||||
6
skills/plugins/claude-code-safety-net/dist/core/analyze/find.d.ts
vendored
Normal file
6
skills/plugins/claude-code-safety-net/dist/core/analyze/find.d.ts
vendored
Normal 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;
|
||||
2
skills/plugins/claude-code-safety-net/dist/core/analyze/interpreters.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/core/analyze/interpreters.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export declare function extractInterpreterCodeArg(tokens: readonly string[]): string | null;
|
||||
export declare function containsDangerousCode(code: string): boolean;
|
||||
9
skills/plugins/claude-code-safety-net/dist/core/analyze/parallel.d.ts
vendored
Normal file
9
skills/plugins/claude-code-safety-net/dist/core/analyze/parallel.d.ts
vendored
Normal 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[];
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/analyze/rm-flags.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/analyze/rm-flags.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function hasRecursiveForceFlags(tokens: readonly string[]): boolean;
|
||||
8
skills/plugins/claude-code-safety-net/dist/core/analyze/segment.d.ts
vendored
Normal file
8
skills/plugins/claude-code-safety-net/dist/core/analyze/segment.d.ts
vendored
Normal 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;
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/analyze/shell-wrappers.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/analyze/shell-wrappers.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function extractDashCArg(tokens: readonly string[]): string | null;
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/analyze/tmpdir.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/analyze/tmpdir.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function isTmpdirOverriddenToNonTemp(envAssignments: Map<string, string>): boolean;
|
||||
14
skills/plugins/claude-code-safety-net/dist/core/analyze/xargs.d.ts
vendored
Normal file
14
skills/plugins/claude-code-safety-net/dist/core/analyze/xargs.d.ts
vendored
Normal 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 {};
|
||||
17
skills/plugins/claude-code-safety-net/dist/core/audit.d.ts
vendored
Normal file
17
skills/plugins/claude-code-safety-net/dist/core/audit.d.ts
vendored
Normal 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;
|
||||
12
skills/plugins/claude-code-safety-net/dist/core/config.d.ts
vendored
Normal file
12
skills/plugins/claude-code-safety-net/dist/core/config.d.ts
vendored
Normal 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 };
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/custom-rules-doc.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/custom-rules-doc.d.ts
vendored
Normal 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";
|
||||
1
skills/plugins/claude-code-safety-net/dist/core/env.d.ts
vendored
Normal file
1
skills/plugins/claude-code-safety-net/dist/core/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function envTruthy(name: string): boolean;
|
||||
10
skills/plugins/claude-code-safety-net/dist/core/format.d.ts
vendored
Normal file
10
skills/plugins/claude-code-safety-net/dist/core/format.d.ts
vendored
Normal 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 {};
|
||||
2
skills/plugins/claude-code-safety-net/dist/core/rules-custom.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/core/rules-custom.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type { CustomRule } from '../types.ts';
|
||||
export declare function checkCustomRules(tokens: string[], rules: CustomRule[]): string | null;
|
||||
8
skills/plugins/claude-code-safety-net/dist/core/rules-git.d.ts
vendored
Normal file
8
skills/plugins/claude-code-safety-net/dist/core/rules-git.d.ts
vendored
Normal 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, };
|
||||
9
skills/plugins/claude-code-safety-net/dist/core/rules-rm.d.ts
vendored
Normal file
9
skills/plugins/claude-code-safety-net/dist/core/rules-rm.d.ts
vendored
Normal 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;
|
||||
15
skills/plugins/claude-code-safety-net/dist/core/shell.d.ts
vendored
Normal file
15
skills/plugins/claude-code-safety-net/dist/core/shell.d.ts
vendored
Normal 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;
|
||||
12
skills/plugins/claude-code-safety-net/dist/core/verify-config.d.ts
vendored
Normal file
12
skills/plugins/claude-code-safety-net/dist/core/verify-config.d.ts
vendored
Normal 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;
|
||||
2
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/commands.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/commands.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type { BuiltinCommandName, BuiltinCommands } from './types.ts';
|
||||
export declare function loadBuiltinCommands(disabledCommands?: BuiltinCommandName[]): BuiltinCommands;
|
||||
2
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/index.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './commands.ts';
|
||||
export * from './types.ts';
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
6
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/types.d.ts
vendored
Normal file
6
skills/plugins/claude-code-safety-net/dist/features/builtin-commands/types.d.ts
vendored
Normal 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>;
|
||||
2
skills/plugins/claude-code-safety-net/dist/index.d.ts
vendored
Normal file
2
skills/plugins/claude-code-safety-net/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import type { Plugin } from '@opencode-ai/plugin';
|
||||
export declare const SafetyNetPlugin: Plugin;
|
||||
2385
skills/plugins/claude-code-safety-net/dist/index.js
vendored
Normal file
2385
skills/plugins/claude-code-safety-net/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
121
skills/plugins/claude-code-safety-net/dist/types.d.ts
vendored
Normal file
121
skills/plugins/claude-code-safety-net/dist/types.d.ts
vendored
Normal 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.)";
|
||||
15
skills/plugins/claude-code-safety-net/hooks/hooks.json
Normal file
15
skills/plugins/claude-code-safety-net/hooks/hooks.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/dist/bin/cc-safety-net.js --claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
skills/plugins/claude-code-safety-net/knip.ts
Normal file
8
skills/plugins/claude-code-safety-net/knip.ts
Normal 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;
|
||||
72
skills/plugins/claude-code-safety-net/package.json
Normal file
72
skills/plugins/claude-code-safety-net/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
46
skills/plugins/claude-code-safety-net/scripts/build.ts
Normal file
46
skills/plugins/claude-code-safety-net/scripts/build.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
164
skills/plugins/claude-code-safety-net/scripts/publish.ts
Normal file
164
skills/plugins/claude-code-safety-net/scripts/publish.ts
Normal 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();
|
||||
6
skills/plugins/claude-code-safety-net/sgconfig.yml
Normal file
6
skills/plugins/claude-code-safety-net/sgconfig.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
ruleDirs:
|
||||
- ast-grep/rules
|
||||
testConfigs:
|
||||
- testDir: ast-grep/rule-tests
|
||||
utilDirs:
|
||||
- ast-grep/utils
|
||||
@@ -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);
|
||||
});
|
||||
81
skills/plugins/claude-code-safety-net/src/bin/claude-code.ts
Normal file
81
skills/plugins/claude-code-safety-net/src/bin/claude-code.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
`;
|
||||
84
skills/plugins/claude-code-safety-net/src/bin/gemini-cli.ts
Normal file
84
skills/plugins/claude-code-safety-net/src/bin/gemini-cli.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
skills/plugins/claude-code-safety-net/src/bin/help.ts
Normal file
32
skills/plugins/claude-code-safety-net/src/bin/help.ts
Normal 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);
|
||||
}
|
||||
117
skills/plugins/claude-code-safety-net/src/bin/statusline.ts
Normal file
117
skills/plugins/claude-code-safety-net/src/bin/statusline.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
132
skills/plugins/claude-code-safety-net/src/bin/verify-config.ts
Normal file
132
skills/plugins/claude-code-safety-net/src/bin/verify-config.ts
Normal 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;
|
||||
}
|
||||
32
skills/plugins/claude-code-safety-net/src/core/analyze.ts
Normal file
32
skills/plugins/claude-code-safety-net/src/core/analyze.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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;
|
||||
}
|
||||
125
skills/plugins/claude-code-safety-net/src/core/analyze/find.ts
Normal file
125
skills/plugins/claude-code-safety-net/src/core/analyze/find.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
180
skills/plugins/claude-code-safety-net/src/core/analyze/xargs.ts
Normal file
180
skills/plugins/claude-code-safety-net/src/core/analyze/xargs.ts
Normal 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;
|
||||
}
|
||||
94
skills/plugins/claude-code-safety-net/src/core/audit.ts
Normal file
94
skills/plugins/claude-code-safety-net/src/core/audit.ts
Normal 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;
|
||||
}
|
||||
222
skills/plugins/claude-code-safety-net/src/core/config.ts
Normal file
222
skills/plugins/claude-code-safety-net/src/core/config.ts
Normal 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 };
|
||||
4
skills/plugins/claude-code-safety-net/src/core/env.ts
Normal file
4
skills/plugins/claude-code-safety-net/src/core/env.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function envTruthy(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
return value === '1' || value?.toLowerCase() === 'true';
|
||||
}
|
||||
36
skills/plugins/claude-code-safety-net/src/core/format.ts
Normal file
36
skills/plugins/claude-code-safety-net/src/core/format.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
354
skills/plugins/claude-code-safety-net/src/core/rules-git.ts
Normal file
354
skills/plugins/claude-code-safety-net/src/core/rules-git.ts
Normal 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,
|
||||
};
|
||||
292
skills/plugins/claude-code-safety-net/src/core/rules-rm.ts
Normal file
292
skills/plugins/claude-code-safety-net/src/core/rules-rm.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
442
skills/plugins/claude-code-safety-net/src/core/shell.ts
Normal file
442
skills/plugins/claude-code-safety-net/src/core/shell.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './commands.ts';
|
||||
export * from './types.ts';
|
||||
@@ -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`;
|
||||
@@ -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`;
|
||||
@@ -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>;
|
||||
47
skills/plugins/claude-code-safety-net/src/index.ts
Normal file
47
skills/plugins/claude-code-safety-net/src/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
148
skills/plugins/claude-code-safety-net/src/types.ts
Normal file
148
skills/plugins/claude-code-safety-net/src/types.ts
Normal 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.)';
|
||||
@@ -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
Reference in New Issue
Block a user