Skip to content

Instantly share code, notes, and snippets.

@yanosh-k
Created February 6, 2026 16:37
Show Gist options
  • Select an option

  • Save yanosh-k/09965770f37b3102c22bdf5c59a745ab to your computer and use it in GitHub Desktop.

Select an option

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
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