diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 400e442..a3c24b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: versionable: - 'hooks/**' - 'commands/**' + - 'opencode/**' - 'GitVersion.yml' - name: Check for +semver in commit message @@ -456,11 +457,23 @@ jobs: json.dump(data, f, indent=2) " + - name: Update opencode package version + run: | + python -c " + import json + with open('opencode/package.json', 'r') as f: + data = json.load(f) + data['version'] = '${{ needs.version.outputs.majorMinorPatch }}' + with open('opencode/package.json', 'w') as f: + json.dump(data, f, indent=2) + f.write('\n') + " + - name: Commit and tag run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add .claude-plugin/plugin.json + git add .claude-plugin/plugin.json opencode/package.json git commit -m "Bump version to ${{ needs.version.outputs.majorMinorPatch }}" git push origin main git tag -a "v${{ needs.version.outputs.majorMinorPatch }}" -m "Release v${{ needs.version.outputs.majorMinorPatch }}" @@ -510,3 +523,32 @@ jobs: git commit -m "Update block plugin to v${{ needs.version.outputs.majorMinorPatch }}" git push origin main ) + + publish-npm: + name: Publish to npm + runs-on: ubuntu-latest + needs: [version, tag] + if: github.ref == 'refs/heads/main' + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: main + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm for OIDC trusted publishing + run: npm install -g npm@^11.5.1 + + - name: Publish to npm + run: | + cd opencode + npm publish --access public --provenance diff --git a/CLAUDE.md b/CLAUDE.md index bf05644..433a5f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,20 +4,24 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a Claude Code plugin that provides file and directory protection using `.block` configuration files. When installed, the plugin intercepts file modification operations (Edit, Write, NotebookEdit, Bash) and blocks them based on protection rules. +This plugin provides file and directory protection using `.block` configuration files. It supports both **Claude Code** and **OpenCode**. When installed, the plugin intercepts file modification operations and blocks them based on protection rules. ## Architecture -The plugin uses Claude Code's hook system: -- **PreToolUse hook**: Runs `protect_directories.py` to check if the target file is protected before allowing Edit, Write, NotebookEdit, or Bash operations +The core protection logic lives in `hooks/protect_directories.py` (Python, no external dependencies). Both Claude Code and OpenCode integrations call this script. -Key files: -- `hooks/hooks.json` - Hook configuration that triggers protection checks -- `hooks/protect_directories.py` - Main protection logic (Python, no external dependencies) +### Claude Code integration +- **PreToolUse hook**: Runs `protect_directories.py` to check if the target file is protected before allowing Edit, Write, NotebookEdit, or Bash operations +- `hooks/hooks.json` - Hook configuration - `hooks/run-hook.cmd` - Cross-platform entry point (polyglot script) - `commands/create.md` - Interactive command for creating `.block` files - `.claude-plugin/plugin.json` - Plugin metadata +### OpenCode integration +- **tool.execute.before hook**: TypeScript plugin that calls `protect_directories.py` before edit, write, bash, or patch operations +- `opencode/index.ts` - Plugin entry point +- `opencode/package.json` - npm package metadata + ## Dependencies - **Python 3.8+** - Required for the protection hook (no external packages needed) diff --git a/README.md b/README.md index 49af937..e77256d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Block -**A Claude Code plugin to protect files from unwanted modifications.** +**Protect files from unwanted AI modifications in [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [OpenCode](https://opencode.ai).** -Drop a `.block` file in any directory to control what Claude can and cannot edit. Protect configs, lock files, migrations, or entire directories with simple pattern rules. +Drop a `.block` file in any directory to control what AI agents can and cannot edit. Protect configs, lock files, migrations, or entire directories with simple pattern rules. ## Why use this? @@ -17,6 +17,8 @@ Drop a `.block` file in any directory to control what Claude can and cannot edit ## Installation +### Claude Code + 1. Register the marketplace: ``` @@ -29,6 +31,37 @@ Drop a `.block` file in any directory to control what Claude can and cannot edit /plugin install block@block-marketplace ``` +### OpenCode + +Add the plugin to your `opencode.json` config: + +```json +{ + "plugins": ["opencode-block"] +} +``` + +Or for local development, clone this repo and reference the plugin directly: + +```json +{ + "plugins": ["file:///path/to/block/opencode/index.ts"] +} +``` + +You can also set up the plugin manually by copying files into your project. The plugin expects `hooks/protect_directories.py` to be a sibling of the directory containing `index.ts`: + +``` +your-project/ +├── .opencode/ +│ └── plugin/ +│ └── index.ts # copied from opencode/index.ts +├── hooks/ +│ └── protect_directories.py # copied from hooks/protect_directories.py +``` + +> **Note:** The `tool.execute.before` hook protects tools called by the primary agent. Tools invoked by subagents spawned via the `task` tool may not be intercepted. + ## Usage Use the `/block:create` command to interactively create a `.block` file: @@ -144,7 +177,10 @@ When both files exist in the same directory: ## How It Works -The plugin hooks into Claude's file operations. When Claude tries to modify a file, it checks for `.block` files in the target directory and all parent directories, then combines their rules. +The plugin hooks into file operations from Claude Code and OpenCode. When the AI agent tries to modify a file, the plugin checks for `.block` files in the target directory and all parent directories, then combines their rules. + +- **Claude Code**: Uses a PreToolUse hook to intercept Edit, Write, NotebookEdit, and Bash tools +- **OpenCode**: Uses a `tool.execute.before` hook to intercept edit, write, bash, and patch tools - `.block` files themselves are always protected - Protection cascades to all subdirectories @@ -194,8 +230,11 @@ pytest tests/ -v --cov=hooks --cov-report=term-missing ``` block/ ├── hooks/ -│ ├── protect_directories.py # Main protection logic -│ └── run-hook.cmd # Cross-platform entry point +│ ├── protect_directories.py # Main protection logic (Python) +│ └── run-hook.cmd # Cross-platform entry point (Claude Code) +├── opencode/ +│ ├── index.ts # OpenCode plugin entry point +│ └── package.json # npm package metadata ├── tests/ │ ├── conftest.py # Shared fixtures │ ├── test_basic_protection.py @@ -210,9 +249,9 @@ block/ │ ├── test_wildcards.py │ └── test_edge_cases.py ├── commands/ -│ └── create.md # Interactive command +│ └── create.md # Interactive command (Claude Code) ├── .claude-plugin/ -│ └── plugin.json # Plugin metadata +│ └── plugin.json # Plugin metadata (Claude Code) └── pyproject.toml # Python project config ``` diff --git a/opencode/index.ts b/opencode/index.ts new file mode 100644 index 0000000..8716646 --- /dev/null +++ b/opencode/index.ts @@ -0,0 +1,134 @@ +/** + * Block plugin for OpenCode + * + * Provides file and directory protection using .block configuration files. + * Intercepts file modification tools (edit, write, bash, patch) and blocks + * them based on protection rules defined in .block files. + * + * This is the OpenCode equivalent of the Claude Code PreToolUse hook. + */ +import type { Plugin } from "@opencode-ai/plugin"; +import { resolve } from "path"; + +/** Tools that modify files and should be checked against .block rules. */ +const PROTECTED_TOOLS = new Set(["edit", "write", "bash", "patch"]); + +/** + * Maps OpenCode tool names to the names expected by protect_directories.py. + * The Python script was originally written for Claude Code's tool naming. + */ +const TOOL_NAME_MAP: Record = { + edit: "Edit", + write: "Write", + bash: "Bash", + patch: "Write", +}; + +/** + * Build the JSON input that protect_directories.py expects on stdin. + * + * Claude Code hook input format: + * { "tool_name": "Edit", "tool_input": { "file_path": "..." } } + * { "tool_name": "Bash", "tool_input": { "command": "..." } } + * + * OpenCode uses camelCase args: filePath for edit/write/patch, command for bash. + */ +function buildHookInput( + tool: string, + args: Record, +): string | null { + const toolName = TOOL_NAME_MAP[tool]; + if (!toolName) return null; + + const toolInput: Record = {}; + + if (tool === "bash") { + if (!args.command) return null; + toolInput.command = args.command; + } else { + // edit, write, patch — OpenCode provides the path as "filePath" + if (!args.filePath) return null; + toolInput.file_path = args.filePath; + } + + return JSON.stringify({ tool_name: toolName, tool_input: toolInput }); +} + +/** + * Locate protect_directories.py relative to this plugin file. + * + * NOTE: import.meta.dir is a Bun-specific API. OpenCode runs plugins via Bun, + * so this is safe. Packages installed via npm are cached under + * ~/.cache/opencode/node_modules/. + * + * When installed via npm the layout is: + * node_modules/opencode-block/protect_directories.py (copied by prepack) + * node_modules/opencode-block/index.ts + * + * When used from the repo directly: + * opencode/index.ts + * hooks/protect_directories.py + */ +function findScript(): string { + const pluginDir = import.meta.dir; + // npm-installed: protect_directories.py is copied alongside index.ts + const colocated = resolve(pluginDir, "protect_directories.py"); + try { + const fs = require("fs"); + if (fs.existsSync(colocated)) return colocated; + } catch { + // Fall through to repo layout + } + // Repo layout: ../hooks/protect_directories.py + return resolve(pluginDir, "..", "hooks", "protect_directories.py"); +} + +export const BlockPlugin: Plugin = async ({ $ }) => { + const scriptPath = findScript(); + + return { + "tool.execute.before": async (input, output) => { + if (!PROTECTED_TOOLS.has(input.tool)) return; + + const hookInput = buildHookInput( + input.tool, + output.args as Record, + ); + if (!hookInput) return; + + try { + const result = + await $`echo ${hookInput} | python3 ${scriptPath}`.quiet(); + const stdout = result.stdout.toString().trim(); + if (!stdout) return; + + const decision = JSON.parse(stdout); + if (decision.decision === "block") { + throw new Error(decision.reason); + } + } catch (err: unknown) { + if (err instanceof SyntaxError) { + // Python output wasn't JSON — not a block, ignore + return; + } + // Block errors from our own throw above — re-throw + if (err instanceof Error && err.message) { + const msg = err.message; + // Infrastructure failures (python3 not found, spawn errors) should + // not prevent the operation — log a warning and let it proceed. + if ( + (err as NodeJS.ErrnoException).code === "ENOENT" || + msg.includes("not found") || + msg.includes("No such file") || + msg.includes("python3") + ) { + console.warn(`[block] Protection check skipped: ${msg}`); + return; + } + } + // Re-throw actual block errors and other unexpected failures + throw err; + } + }, + }; +}; diff --git a/opencode/package.json b/opencode/package.json new file mode 100644 index 0000000..773e5ab --- /dev/null +++ b/opencode/package.json @@ -0,0 +1,28 @@ +{ + "name": "opencode-block", + "version": "1.1.14", + "description": "File and directory protection for OpenCode using .block marker files with pattern matching", + "main": "index.ts", + "scripts": { + "prepack": "node -e \"const fs=require('fs');fs.copyFileSync('../hooks/protect_directories.py','protect_directories.py');fs.copyFileSync('../README.md','README.md')\"", + "postpack": "node -e \"const fs=require('fs');['protect_directories.py','README.md'].forEach(f=>{try{fs.unlinkSync(f)}catch(e){}})\"" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "protection", + "security", + "file-blocking", + "directory-lock" + ], + "author": "Iiro Rahkonen", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/kodroi/block" + }, + "files": [ + "index.ts", + "protect_directories.py" + ] +} diff --git a/pyproject.toml b/pyproject.toml index 739a00b..8930b83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "block" version = "1.1.6" -description = "File and directory protection for Claude Code" +description = "File and directory protection for Claude Code and OpenCode" readme = "README.md" license = "MIT" authors = [