From 2d51a3678da660772e68517bfb6f3c9278e731b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 08:59:19 +0000 Subject: [PATCH 1/7] feat: Add OpenCode plugin support Add a TypeScript plugin for OpenCode that reuses the existing protect_directories.py protection logic via tool.execute.before hook. Intercepts edit, write, bash, and patch tools. +semver: minor https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- CLAUDE.md | 16 ++++--- README.md | 42 ++++++++++++++--- opencode/index.ts | 105 ++++++++++++++++++++++++++++++++++++++++++ opencode/package.json | 24 ++++++++++ pyproject.toml | 2 +- 5 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 opencode/index.ts create mode 100644 opencode/package.json 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..5f339dd 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,26 @@ 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 copy `opencode/index.ts` into your project's `.opencode/plugin/` directory along with the `hooks/` folder. + ## Usage Use the `/block:create` command to interactively create a `.block` file: @@ -144,7 +166,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 +219,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 +238,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..1730b2f --- /dev/null +++ b/opencode/index.ts @@ -0,0 +1,105 @@ +/** + * 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/plugin"; +import { resolve, dirname } 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": "..." } } + */ +function buildHookInput( + tool: string, + args: Record, +): string | null { + const toolName = TOOL_NAME_MAP[tool]; + if (!toolName) return null; + + const toolInput: Record = {}; + + if (tool === "bash") { + const command = args.command ?? args.cmd; + if (!command) return null; + toolInput.command = command; + } else { + // edit, write, patch all use filePath + const filePath = args.filePath ?? args.file_path ?? args.file; + if (!filePath) return null; + toolInput.file_path = filePath; + } + + return JSON.stringify({ tool_name: toolName, tool_input: toolInput }); +} + +/** + * Locate protect_directories.py relative to this plugin file. + * When installed via npm the layout is: + * node_modules/opencode-block/opencode/index.ts + * node_modules/opencode-block/hooks/protect_directories.py + * + * When used from the repo directly: + * opencode/index.ts + * hooks/protect_directories.py + */ +function findScript(): string { + const pluginDir = import.meta.dir; + 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; + } + // Re-throw block errors and unexpected failures + throw err; + } + }, + }; +}; diff --git a/opencode/package.json b/opencode/package.json new file mode 100644 index 0000000..27e7d07 --- /dev/null +++ b/opencode/package.json @@ -0,0 +1,24 @@ +{ + "name": "opencode-block", + "version": "1.1.14", + "description": "File and directory protection for OpenCode using .block marker files with pattern matching", + "main": "index.ts", + "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", + "../hooks/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 = [ From c355fb8a7c4610e4b5e36ee46fa1ca152baf3fde Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 09:25:33 +0000 Subject: [PATCH 2/7] fix: Address review feedback for OpenCode plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix import path: opencode/plugin → @opencode-ai/plugin - Remove unused dirname import - Handle python3-not-found gracefully (warn and allow operation) - Add prepack script to copy protect_directories.py for npm publish - Use canonical OpenCode arg names (filePath, command) without fallbacks - Add Bun-specific comment on import.meta.dir - Clarify README manual setup with required directory layout - Document subagent limitation for tool.execute.before hook https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- README.md | 13 ++++++++++- opencode/index.ts | 53 +++++++++++++++++++++++++++++++++---------- opencode/package.json | 6 ++++- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5f339dd..e77256d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,18 @@ Or for local development, clone this repo and reference the plugin directly: } ``` -You can also copy `opencode/index.ts` into your project's `.opencode/plugin/` directory along with the `hooks/` folder. +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 diff --git a/opencode/index.ts b/opencode/index.ts index 1730b2f..8716646 100644 --- a/opencode/index.ts +++ b/opencode/index.ts @@ -7,8 +7,8 @@ * * This is the OpenCode equivalent of the Claude Code PreToolUse hook. */ -import type { Plugin } from "opencode/plugin"; -import { resolve, dirname } from "path"; +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"]); @@ -30,6 +30,8 @@ const TOOL_NAME_MAP: Record = { * 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, @@ -41,14 +43,12 @@ function buildHookInput( const toolInput: Record = {}; if (tool === "bash") { - const command = args.command ?? args.cmd; - if (!command) return null; - toolInput.command = command; + if (!args.command) return null; + toolInput.command = args.command; } else { - // edit, write, patch all use filePath - const filePath = args.filePath ?? args.file_path ?? args.file; - if (!filePath) return null; - toolInput.file_path = filePath; + // 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 }); @@ -56,9 +56,14 @@ function buildHookInput( /** * 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/opencode/index.ts - * node_modules/opencode-block/hooks/protect_directories.py + * 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 @@ -66,6 +71,15 @@ function buildHookInput( */ 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"); } @@ -97,7 +111,22 @@ export const BlockPlugin: Plugin = async ({ $ }) => { // Python output wasn't JSON — not a block, ignore return; } - // Re-throw block errors and unexpected failures + // 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 index 27e7d07..b8e12f7 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -3,6 +3,10 @@ "version": "1.1.14", "description": "File and directory protection for OpenCode using .block marker files with pattern matching", "main": "index.ts", + "scripts": { + "prepack": "cp ../hooks/protect_directories.py protect_directories.py", + "postpack": "rm -f protect_directories.py" + }, "keywords": [ "opencode", "opencode-plugin", @@ -19,6 +23,6 @@ }, "files": [ "index.ts", - "../hooks/protect_directories.py" + "protect_directories.py" ] } From 0ee98e8665915ce5e3acbbf696673be4de16303c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:06:49 +0000 Subject: [PATCH 3/7] feat: Add automated npm publish for opencode-block package Add publish-npm job to CI that publishes to npm after tagging. Sync opencode/package.json version in the tag job and include opencode/** in the versionable paths filter. Requires NPM_TOKEN repository secret. https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 400e442..583b10f 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,33 @@ 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' + + 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: Update package version + run: | + cd opencode + npm version ${{ needs.version.outputs.majorMinorPatch }} --no-git-tag-version + + - name: Publish to npm + run: | + cd opencode + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From e32172c1c52fdec8210a24d8ff5287ed2efdcf40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:08:28 +0000 Subject: [PATCH 4/7] feat: Use npm trusted publishing (OIDC) instead of NPM_TOKEN Replace long-lived NPM_TOKEN secret with GitHub Actions OIDC id-token permission for secure, tokenless npm publishing. Provenance attestations are generated automatically. https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 583b10f..f0d09e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,6 +529,9 @@ jobs: runs-on: ubuntu-latest needs: [version, tag] if: github.ref == 'refs/heads/main' + permissions: + contents: read + id-token: write steps: - name: Checkout repository @@ -551,5 +554,3 @@ jobs: run: | cd opencode npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 918b3f7f758e4e8453598257bbbbfa7e144e5b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:18:37 +0000 Subject: [PATCH 5/7] fix: Make prepack/postpack scripts cross-platform Replace cp/rm with Node.js fs calls so npm pack works on Windows. https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- opencode/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencode/package.json b/opencode/package.json index b8e12f7..858af78 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -4,8 +4,8 @@ "description": "File and directory protection for OpenCode using .block marker files with pattern matching", "main": "index.ts", "scripts": { - "prepack": "cp ../hooks/protect_directories.py protect_directories.py", - "postpack": "rm -f protect_directories.py" + "prepack": "node -e \"require('fs').copyFileSync('../hooks/protect_directories.py','protect_directories.py')\"", + "postpack": "node -e \"try{require('fs').unlinkSync('protect_directories.py')}catch(e){}\"" }, "keywords": [ "opencode", From 65cf87f32396b2baf775c8a91514e0724c304618 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:20:18 +0000 Subject: [PATCH 6/7] fix: Include README.md in opencode-block npm package Copy README.md from repo root during prepack so it appears on the npm package page. https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- opencode/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencode/package.json b/opencode/package.json index 858af78..773e5ab 100644 --- a/opencode/package.json +++ b/opencode/package.json @@ -4,8 +4,8 @@ "description": "File and directory protection for OpenCode using .block marker files with pattern matching", "main": "index.ts", "scripts": { - "prepack": "node -e \"require('fs').copyFileSync('../hooks/protect_directories.py','protect_directories.py')\"", - "postpack": "node -e \"try{require('fs').unlinkSync('protect_directories.py')}catch(e){}\"" + "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", From 295f6483c960e4121045e1f2916149ed951c5486 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 10:21:59 +0000 Subject: [PATCH 7/7] fix: Fix publish-npm job for OIDC trusted publishing - Remove redundant npm version step (tag job already updates package.json) - Upgrade npm to 11.5+ (Node 20's npm 10.x lacks OIDC support) - Add --provenance flag for supply chain attestations https://claude.ai/code/session_011KMGYhQjWHZKReC4TS4xa8 --- .github/workflows/ci.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0d09e4..a3c24b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -545,12 +545,10 @@ jobs: node-version: '20' registry-url: 'https://registry.npmjs.org' - - name: Update package version - run: | - cd opencode - npm version ${{ needs.version.outputs.majorMinorPatch }} --no-git-tag-version + - 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 + npm publish --access public --provenance