Created
February 6, 2026 16:37
-
-
Save yanosh-k/09965770f37b3102c22bdf5c59a745ab to your computer and use it in GitHub Desktop.
Plugin for opencode that forbids the reading of files listed in .aiexclude
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Plugin } from "@opencode-ai/plugin" | |
| // ============================================================================ | |
| // Embedded ignore library (from node-ignore) | |
| // ============================================================================ | |
| function makeArray(subject: any): any[] { | |
| return Array.isArray(subject) ? subject : [subject] | |
| } | |
| const REGEX_TEST_BLANK_LINE = /^\s+$/ | |
| const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/ | |
| const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/ | |
| const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/ | |
| const REGEX_SPLITALL_CRLF = /\r?\n/g | |
| const REGEX_TEST_INVALID_PATH = /^\.{0,2}\/|^\.{1,2}$/ | |
| const REGEX_TEST_TRAILING_SLASH = /\/$/ | |
| const SLASH = '/' | |
| const KEY_IGNORE = Symbol.for('node-ignore') | |
| const EMPTY = '' | |
| const SPACE = ' ' | |
| const ESCAPE = '\\' | |
| const UNDERSCORE = '_' | |
| const define = (object: any, key: string | symbol, value: any) => { | |
| Object.defineProperty(object, key, { value }) | |
| return value | |
| } | |
| const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g | |
| const RETURN_FALSE = () => false | |
| const sanitizeRange = (range: string) => range.replace( | |
| REGEX_REGEXP_RANGE, | |
| (match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0) ? match : EMPTY | |
| ) | |
| const cleanRangeBackSlash = (slashes: string) => { | |
| const { length } = slashes | |
| return slashes.slice(0, length - length % 2) | |
| } | |
| const REPLACERS: Array<[RegExp, (this: string, ...args: any[]) => string]> = [ | |
| [/^\uFEFF/, () => EMPTY], | |
| [/((?:\\\\)*?)(\\?\s+)$/, (_, m1, m2) => m1 + (m2.indexOf('\\') === 0 ? SPACE : EMPTY)], | |
| [/(\\+?)\s/g, (_, m1) => { | |
| const { length } = m1 | |
| return m1.slice(0, length - length % 2) + SPACE | |
| }], | |
| [/[\\$.|*+(){^]/g, match => `\\${match}`], | |
| [/(?!\\)\?/g, () => '[^/]'], | |
| [/^\//, () => '^'], | |
| [/\//g, () => '\\/'], | |
| [/^\^*\\\*\\\*\\\//, () => '^(?:.*\\/)?'], | |
| [/^(?=[^^])/, function startingReplacer(this: string) { | |
| return !/\/(?!$)/.test(this) ? '(?:^|\\/)' : '^' | |
| }], | |
| [/\\\/\\\*\\\*(?=\\\/|$)/g, (_, index, str) => | |
| index + 6 < str.length ? '(?:\\/[^\\/]+)*' : '\\/.+' | |
| ], | |
| [/(^|[^\\]+)(\\\*)+(?=.+)/g, (_, p1, p2) => { | |
| const unescaped = p2.replace(/\\\*/g, '[^\\/]*') | |
| return p1 + unescaped | |
| }], | |
| [/\\\\\\(?=[$.|*+(){^])/g, () => ESCAPE], | |
| [/\\\\/g, () => ESCAPE], | |
| [/(\\)?\[([^\]/]*?)(\\*)($|\])/g, (match, leadEscape, range, endEscape, close) => | |
| leadEscape === ESCAPE | |
| ? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` | |
| : close === ']' | |
| ? endEscape.length % 2 === 0 | |
| ? `[${sanitizeRange(range)}${endEscape}]` | |
| : '[]' | |
| : '[]' | |
| ], | |
| [/(?:[^*])$/, match => /\/$/.test(match) ? `${match}$` : `${match}(?=$|\\/)`] | |
| ] | |
| const REGEX_REPLACE_TRAILING_WILDCARD = /(^|\\\/)?\\\*$/ | |
| const MODE_IGNORE = 'regex' | |
| const MODE_CHECK_IGNORE = 'checkRegex' | |
| const TRAILING_WILD_CARD_REPLACERS: Record<string, (match: string, p1: string) => string> = { | |
| [MODE_IGNORE](_, p1) { | |
| const prefix = p1 ? `${p1}[^/]+` : '[^/]*' | |
| return `${prefix}(?=$|\\/)` | |
| }, | |
| [MODE_CHECK_IGNORE](_, p1) { | |
| const prefix = p1 ? `${p1}[^/]*` : '[^/]*' | |
| return `${prefix}(?=$|\\/)` | |
| } | |
| } | |
| const makeRegexPrefix = (pattern: string) => REPLACERS.reduce( | |
| (prev, [matcher, replacer]) => prev.replace(matcher, replacer.bind(pattern)), | |
| pattern | |
| ) | |
| const isString = (subject: any): subject is string => typeof subject === 'string' | |
| const checkPattern = (pattern: string) => pattern | |
| && isString(pattern) | |
| && !REGEX_TEST_BLANK_LINE.test(pattern) | |
| && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) | |
| && pattern.indexOf('#') !== 0 | |
| const splitPattern = (pattern: string) => pattern.split(REGEX_SPLITALL_CRLF).filter(Boolean) | |
| class IgnoreRule { | |
| pattern: string | |
| mark: string | undefined | |
| negative: boolean | |
| private body: string | |
| private ignoreCase: boolean | |
| private regexPrefix: string | |
| private _regex?: RegExp | |
| private _checkRegex?: RegExp | |
| constructor( | |
| pattern: string, | |
| mark: string | undefined, | |
| body: string, | |
| ignoreCase: boolean, | |
| negative: boolean, | |
| prefix: string | |
| ) { | |
| this.pattern = pattern | |
| this.mark = mark | |
| this.negative = negative | |
| define(this, 'body', body) | |
| define(this, 'ignoreCase', ignoreCase) | |
| define(this, 'regexPrefix', prefix) | |
| } | |
| get regex(): RegExp { | |
| const key = UNDERSCORE + MODE_IGNORE | |
| if ((this as any)[key]) { | |
| return (this as any)[key] | |
| } | |
| return this._make(MODE_IGNORE, key) | |
| } | |
| get checkRegex(): RegExp { | |
| const key = UNDERSCORE + MODE_CHECK_IGNORE | |
| if ((this as any)[key]) { | |
| return (this as any)[key] | |
| } | |
| return this._make(MODE_CHECK_IGNORE, key) | |
| } | |
| _make(mode: string, key: string): RegExp { | |
| const str = this.regexPrefix.replace( | |
| REGEX_REPLACE_TRAILING_WILDCARD, | |
| TRAILING_WILD_CARD_REPLACERS[mode] | |
| ) | |
| const regex = this.ignoreCase ? new RegExp(str, 'i') : new RegExp(str) | |
| return define(this, key, regex) | |
| } | |
| } | |
| const createRule = ( | |
| { pattern, mark }: { pattern: string; mark?: string }, | |
| ignoreCase: boolean | |
| ): IgnoreRule => { | |
| let negative = false | |
| let body = pattern | |
| if (body.indexOf('!') === 0) { | |
| negative = true | |
| body = body.substr(1) | |
| } | |
| body = body | |
| .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!') | |
| .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#') | |
| const regexPrefix = makeRegexPrefix(body) | |
| return new IgnoreRule(pattern, mark, body, ignoreCase, negative, regexPrefix) | |
| } | |
| interface TestResult { | |
| ignored: boolean | |
| unignored: boolean | |
| rule?: IgnoreRule | |
| } | |
| class RuleManager { | |
| private _ignoreCase: boolean | |
| private _rules: IgnoreRule[] | |
| private _added: boolean | |
| constructor(ignoreCase: boolean) { | |
| this._ignoreCase = ignoreCase | |
| this._rules = [] | |
| this._added = false | |
| } | |
| _add(pattern: any) { | |
| if (pattern && pattern[KEY_IGNORE]) { | |
| this._rules = this._rules.concat(pattern._rules._rules) | |
| this._added = true | |
| return | |
| } | |
| if (isString(pattern)) { | |
| pattern = { pattern } | |
| } | |
| if (checkPattern(pattern.pattern)) { | |
| const rule = createRule(pattern, this._ignoreCase) | |
| this._added = true | |
| this._rules.push(rule) | |
| } | |
| } | |
| add(pattern: any): boolean { | |
| this._added = false | |
| makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._add, this) | |
| return this._added | |
| } | |
| test(path: string, checkUnignored: boolean, mode: string): TestResult { | |
| let ignored = false | |
| let unignored = false | |
| let matchedRule: IgnoreRule | undefined | |
| this._rules.forEach(rule => { | |
| const { negative } = rule | |
| if ( | |
| unignored === negative && ignored !== unignored | |
| || negative && !ignored && !unignored && !checkUnignored | |
| ) { | |
| return | |
| } | |
| const regex = mode === MODE_IGNORE ? rule.regex : rule.checkRegex | |
| const matched = regex.test(path) | |
| if (!matched) { | |
| return | |
| } | |
| ignored = !negative | |
| unignored = negative | |
| matchedRule = negative ? undefined : rule | |
| }) | |
| const ret: TestResult = { ignored, unignored } | |
| if (matchedRule) { | |
| ret.rule = matchedRule | |
| } | |
| return ret | |
| } | |
| } | |
| const throwError = (message: string, Ctor: any) => { | |
| throw new Ctor(message) | |
| } | |
| const checkPath = (filepath: string, originalPath: string, doThrow: typeof throwError | typeof RETURN_FALSE): boolean => { | |
| if (!isString(filepath)) { | |
| return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError) | |
| } | |
| if (!filepath) { | |
| return doThrow(`path must not be empty`, TypeError) | |
| } | |
| if (isNotRelative(filepath)) { | |
| const r = '`path.relative()`d' | |
| return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError) | |
| } | |
| return true | |
| } | |
| const isNotRelative = (filepath: string) => REGEX_TEST_INVALID_PATH.test(filepath) | |
| class Ignore { | |
| private _rules: RuleManager | |
| private _strictPathCheck: boolean | |
| private _ignoreCache: Record<string, TestResult> | |
| private _testCache: Record<string, TestResult> | |
| constructor({ ignoreCase = true, allowRelativePaths = false } = {}) { | |
| define(this, KEY_IGNORE, true) | |
| this._rules = new RuleManager(ignoreCase) | |
| this._strictPathCheck = !allowRelativePaths | |
| this._initCache() | |
| } | |
| _initCache() { | |
| this._ignoreCache = Object.create(null) | |
| this._testCache = Object.create(null) | |
| } | |
| add(pattern: any): this { | |
| if (this._rules.add(pattern)) { | |
| this._initCache() | |
| } | |
| return this | |
| } | |
| _test(originalPath: string, cache: Record<string, TestResult>, checkUnignored: boolean, slices?: string[]): TestResult { | |
| const filepath = originalPath | |
| checkPath(filepath, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE) | |
| return this._t(filepath, cache, checkUnignored, slices) | |
| } | |
| _t(filepath: string, cache: Record<string, TestResult>, checkUnignored: boolean, slices?: string[]): TestResult { | |
| if (filepath in cache) { | |
| return cache[filepath] | |
| } | |
| if (!slices) { | |
| slices = filepath.split(SLASH).filter(Boolean) | |
| } | |
| slices.pop() | |
| if (!slices.length) { | |
| return cache[filepath] = this._rules.test(filepath, checkUnignored, MODE_IGNORE) | |
| } | |
| const parent = this._t(slices.join(SLASH) + SLASH, cache, checkUnignored, slices) | |
| return cache[filepath] = parent.ignored | |
| ? parent | |
| : this._rules.test(filepath, checkUnignored, MODE_IGNORE) | |
| } | |
| ignores(filepath: string): boolean { | |
| return this._test(filepath, this._ignoreCache, false).ignored | |
| } | |
| test(filepath: string): TestResult { | |
| return this._test(filepath, this._testCache, true) | |
| } | |
| } | |
| const createIgnore = (options?: any) => new Ignore(options) | |
| // ============================================================================ | |
| // Plugin Implementation | |
| // ============================================================================ | |
| interface AiExcludeCache { | |
| ignore: Ignore | |
| aiexcludePath: string | |
| } | |
| export const AiExcludePlugin: Plugin = async ({ project, client, directory, worktree }) => { | |
| const cache = new Map<string, AiExcludeCache>() | |
| // Find all .aiexclude files from current directory up to worktree root | |
| const findAiExcludeFiles = async (startDir: string): Promise<string[]> => { | |
| const files: string[] = [] | |
| let currentDir = startDir | |
| const rootDir = worktree || directory | |
| while (true) { | |
| const aiexcludePath = currentDir + '/.aiexclude' | |
| try { | |
| const file = Bun.file(aiexcludePath) | |
| // Try to check if file exists by attempting to get its size | |
| const exists = await file.exists() | |
| if (exists) { | |
| files.push(aiexcludePath) | |
| } | |
| } catch { | |
| // File doesn't exist, continue | |
| } | |
| const parentDir = currentDir.split('/').slice(0, -1).join('/') || '/' | |
| if (currentDir === rootDir || currentDir === parentDir || currentDir === '/') { | |
| break | |
| } | |
| currentDir = parentDir | |
| } | |
| return files.reverse() // Parent directories first for cascading | |
| } | |
| // Load and parse .aiexclude files | |
| const loadAiExclude = async (baseDir: string): Promise<Ignore | null> => { | |
| const cacheKey = baseDir | |
| if (cache.has(cacheKey)) { | |
| return cache.get(cacheKey)!.ignore | |
| } | |
| const aiexcludeFiles = await findAiExcludeFiles(baseDir) | |
| if (aiexcludeFiles.length === 0) { | |
| return null | |
| } | |
| const ignore = createIgnore({ ignoreCase: false, allowRelativePaths: true }) | |
| // Load patterns from all .aiexclude files (cascading) | |
| for (const aiexcludePath of aiexcludeFiles) { | |
| try { | |
| const content = await Bun.file(aiexcludePath).text() | |
| ignore.add(content) | |
| await client.app.log({ | |
| service: 'aiexclude', | |
| level: 'debug', | |
| message: `Loaded .aiexclude from ${aiexcludePath}`, | |
| }) | |
| } catch (error) { | |
| await client.app.log({ | |
| service: 'aiexclude', | |
| level: 'warn', | |
| message: `Failed to read .aiexclude: ${aiexcludePath}`, | |
| extra: { error: String(error) }, | |
| }) | |
| } | |
| } | |
| cache.set(cacheKey, { ignore, aiexcludePath: aiexcludeFiles[0] }) | |
| return ignore | |
| } | |
| // Convert absolute path to relative path for matching | |
| const toRelativePath = (absolutePath: string, baseDir: string): string => { | |
| if (absolutePath.startsWith('/')) { | |
| // Make relative to baseDir | |
| if (absolutePath.startsWith(baseDir)) { | |
| return absolutePath.slice(baseDir.length + 1) | |
| } | |
| } | |
| return absolutePath | |
| } | |
| // Check if a path is excluded | |
| const isExcluded = async (filepath: string, baseDir: string): Promise<{ excluded: boolean; pattern?: string }> => { | |
| const ignore = await loadAiExclude(baseDir) | |
| if (!ignore) { | |
| return { excluded: false } | |
| } | |
| const relativePath = toRelativePath(filepath, baseDir) | |
| const result = ignore.test(relativePath) | |
| if (result.ignored) { | |
| return { | |
| excluded: true, | |
| pattern: result.rule?.pattern || 'unknown pattern' | |
| } | |
| } | |
| return { excluded: false } | |
| } | |
| // Detect file operations in bash commands | |
| const detectFileInBashCommand = (command: string): string[] => { | |
| const files: string[] = [] | |
| // Common file operation patterns | |
| const filePatterns = [ | |
| // Text viewers/editors (handle flags like -n 100) | |
| /(?:cat|tac|less|more|head|tail|vim|vi|nano|emacs|code|bat|view|rev|nl)\s+(?:-[a-zA-Z0-9]+\s+\S+\s+)*(?:-[a-zA-Z0-9]+\s+)*([^\s;|&<>-][^\s;|&<>]*)/g, | |
| // Binary inspection tools | |
| /(?:strings|xxd|hexdump|od|hd)\s+(?:-[a-zA-Z0-9]+\s+)*([^\s;|&<>-][^\s;|&<>]*)/g, | |
| // File operations | |
| /(?:cp|mv|rm|chmod|chown|touch|ln)\s+(?:-[a-z]+\s+)?([^\s;|&<>]+)/g, | |
| // Text processing - including grep . pattern | |
| /(?:grep|egrep|fgrep|rg)\s+(?:-[a-zA-Z0-9]+\s+)?(?:'[^']+'|"[^"]+"|\S+)?\s+([^\s;|&<>]+)/g, | |
| /(?:sed|awk|cut|sort|uniq|tr|column)\s+(?:-[a-zA-Z0-9]+\s+)?(?:'[^']+'|"[^"]+")?\s+([^\s;|&<>]+)/g, | |
| // File comparison | |
| /(?:diff|cmp|comm|patch)\s+(?:-[a-zA-Z0-9]+\s+)*([^\s;|&<>-][^\s;|&<>]*)/g, | |
| // File metadata | |
| /(?:file|stat|wc|md5sum|sha256sum|sha1sum|cksum)\s+([^\s;|&<>]+)/g, | |
| // Compression/archiving | |
| /(?:tar|zip|unzip|gzip|gunzip|bzip2|bunzip2|7z)\s+(?:-[a-zA-Z0-9]+\s+)*([^\s;|&<>-][^\s;|&<>]*)/g, | |
| // Scripting languages with file operations | |
| /(?:python[0-9.]*|python3?|node|ruby|perl|php)\s+(?:-[a-zA-Z]+\s+)?-c\s+['"].*?(?:open|read|readFile|File\.read)[^'"]*['"][^'"]*['"]([^'"]+)['"]/g, | |
| // Direct file reading in scripts (common patterns) | |
| /(?:open|read|readFile|readFileSync|file_get_contents)\s*\(\s*['"]([^'"]+)['"]/g, | |
| // Redirection | |
| /(?:<|>|>>)\s*([^\s;|&<>]+)/g, | |
| ] | |
| for (const pattern of filePatterns) { | |
| let match | |
| while ((match = pattern.exec(command)) !== null) { | |
| const file = match[1] | |
| if (file && !file.startsWith('-') && file !== '/dev/null' && file !== '/dev/stdin' && file !== '/dev/stdout' && file !== '/dev/stderr') { | |
| files.push(file) | |
| } | |
| } | |
| } | |
| // Also check for scripting language one-liners that might read files | |
| // Pattern: python -c "..." or node -e "..." with file paths | |
| const scriptPatterns = [ | |
| /(['"])([^'"]*?\.(secret|key|credentials|env)[^'"]*?)['"]/g, // Any quoted paths with sensitive extensions | |
| /(?:python[0-9.]*|node|ruby|perl)\s.*?(['"])([^'"]+\.(?:secret|key|credentials|env|txt|json|yaml|yml|conf|cfg|ini))\1/g, | |
| ] | |
| for (const pattern of scriptPatterns) { | |
| let match | |
| while ((match = pattern.exec(command)) !== null) { | |
| const file = match[2] || match[1] | |
| if (file && file.includes('/') || file.includes('.')) { | |
| files.push(file) | |
| } | |
| } | |
| } | |
| return files | |
| } | |
| // Don't await - just fire and forget to avoid blocking plugin initialization | |
| client.app.log({ | |
| service: 'aiexclude', | |
| level: 'info', | |
| message: 'AiExclude plugin initialized', | |
| extra: { directory, worktree }, | |
| }).catch(() => {}) | |
| return { | |
| "tool.execute.before": async (input, output) => { | |
| const baseDir = worktree || directory | |
| try { | |
| // Handle different tool types | |
| if (input.tool === 'read' || input.tool === 'write' || input.tool === 'edit') { | |
| const filepath = output.args.filePath | |
| if (filepath) { | |
| const { excluded, pattern } = await isExcluded(filepath, baseDir) | |
| if (excluded) { | |
| throw new Error( | |
| `File excluded by .aiexclude: ${filepath}\n` + | |
| `Matched pattern: ${pattern}\n` + | |
| `This file cannot be accessed by AI tools.` | |
| ) | |
| } | |
| } | |
| } else if (input.tool === 'glob') { | |
| const searchPath = output.args.path || baseDir | |
| const { excluded, pattern } = await isExcluded(searchPath, baseDir) | |
| if (excluded) { | |
| throw new Error( | |
| `Path excluded by .aiexclude: ${searchPath}\n` + | |
| `Matched pattern: ${pattern}` | |
| ) | |
| } | |
| } else if (input.tool === 'grep') { | |
| const searchPath = output.args.path || baseDir | |
| const { excluded, pattern } = await isExcluded(searchPath, baseDir) | |
| if (excluded) { | |
| throw new Error( | |
| `Path excluded by .aiexclude: ${searchPath}\n` + | |
| `Matched pattern: ${pattern}` | |
| ) | |
| } | |
| } else if (input.tool === 'bash') { | |
| const command = output.args.command | |
| if (command) { | |
| const files = detectFileInBashCommand(command) | |
| for (const file of files) { | |
| const filepath = file.startsWith('/') ? file : baseDir + '/' + file | |
| const { excluded, pattern } = await isExcluded(filepath, baseDir) | |
| if (excluded) { | |
| throw new Error( | |
| `Bash command blocked: attempts to access excluded file\n` + | |
| `File: ${file}\n` + | |
| `Matched pattern: ${pattern}\n` + | |
| `Command: ${command}` | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| // Log the exclusion | |
| await client.app.log({ | |
| service: 'aiexclude', | |
| level: 'warn', | |
| message: 'Tool execution blocked by .aiexclude', | |
| extra: { | |
| tool: input.tool, | |
| args: output.args, | |
| error: String(error), | |
| }, | |
| }) | |
| throw error | |
| } | |
| }, | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment