// gstack-guardian.js — OpenCode plugin for gstack safety skills // Integrates: /careful, /freeze, /guard // // Usage: Place this file in your OpenCode plugin directory // or reference it in your OpenCode configuration. const fs = require('fs'); const path = require('path'); // State file for freeze boundary const FREEZE_STATE = path.join(process.env.HOME, '.gstack', 'freeze-dir.txt'); // Destructive command patterns const DANGEROUS_PATTERNS = [ { pattern: /rm\s+(-[a-zA-Z]*r|--recursive)/, message: 'Destructive: recursive delete (rm -r). This permanently removes files.', name: 'rm_recursive' }, { pattern: /DROP\s+(TABLE|DATABASE)/i, message: 'Destructive: SQL DROP detected. This permanently deletes database objects.', name: 'drop_table' }, { pattern: /\bTRUNCATE\b/i, message: 'Destructive: SQL TRUNCATE detected. This deletes all rows from a table.', name: 'truncate' }, { pattern: /git\s+push\s+.*(-f\b|--force)/, message: 'Destructive: git force-push rewrites remote history. Other contributors may lose work.', name: 'git_force_push' }, { pattern: /git\s+reset\s+--hard/, message: 'Destructive: git reset --hard discards all uncommitted changes.', name: 'git_reset_hard' }, { pattern: /git\s+(checkout|restore)\s+\./, message: 'Destructive: discards all uncommitted changes in the working tree.', name: 'git_discard' }, { pattern: /kubectl\s+delete/, message: 'Destructive: kubectl delete removes Kubernetes resources. May impact production.', name: 'kubectl_delete' }, { pattern: /docker\s+(rm\s+-f|system\s+prune)/, message: 'Destructive: Docker force-remove or prune. May delete running containers or cached images.', name: 'docker_destructive' }, ]; // Safe rm targets (build artifacts) const SAFE_RM_TARGETS = [ 'node_modules', '.next', 'dist', '__pycache__', '.cache', 'build', '.turbo', 'coverage', ]; function isSafeRm(cmd) { // Check if this is rm -r targeting only safe directories const rmMatch = cmd.match(/rm\s+(-[a-zA-Z]+\s+)*/); if (!rmMatch) return false; const args = cmd.replace(/rm\s+(-[a-zA-Z]+\s+)*/, '').trim(); const targets = args.split(/\s+/); return targets.every(target => { if (target.startsWith('-')) return true; const basename = path.basename(target); return SAFE_RM_TARGETS.includes(basename); }); } function checkCareful(cmd) { // Skip if it's a safe rm if (/rm\s+(-[a-zA-Z]*r|--recursive)/.test(cmd) && isSafeRm(cmd)) { return null; } // Check dangerous patterns for (const { pattern, message, name } of DANGEROUS_PATTERNS) { if (pattern.test(cmd)) { return { message: `[careful] ${message}`, pattern: name }; } } return null; } function checkFreeze(filePath) { try { const freezeDir = fs.readFileSync(FREEZE_STATE, 'utf-8').trim(); if (!freezeDir) return null; const resolvedPath = path.resolve(filePath); const resolvedBoundary = path.resolve(freezeDir); if (!resolvedPath.startsWith(resolvedBoundary)) { return { message: `[freeze] Blocked: ${filePath} is outside the freeze boundary (${freezeDir}). Only edits within the frozen directory are allowed.`, }; } } catch (e) { if (e.code !== 'ENOENT') throw e; } return null; } module.exports = async ({ project, client, $, directory, worktree }) => { // Read guard state const guardStateFile = path.join(process.env.HOME, '.gstack', 'guard-active.txt'); function isGuardActive() { try { return fs.existsSync(guardStateFile); } catch { return false; } } function isCarefulActive() { try { return fs.existsSync(path.join(process.env.HOME, '.gstack', 'careful-active.txt')); } catch { return false; } } function isFreezeActive() { try { return fs.existsSync(FREEZE_STATE); } catch { return false; } } return { 'tool.execute.before': async (input, output) => { const guardActive = isGuardActive(); const carefulActive = isCarefulActive() || guardActive; const freezeActive = isFreezeActive() || guardActive; // === Careful: Destructive command interception === if (carefulActive && input.tool === 'bash') { const cmd = output.args?.command || ''; const result = checkCareful(cmd); if (result) { throw new Error( `⚠️ GStack Guard: Destructive command intercepted\n` + `Command: ${cmd}\n` + `Pattern: ${result.pattern}\n` + `Message: ${result.message}\n` + `To override, disable /careful or /guard first.` ); } } // === Freeze: Edit directory restriction === if (freezeActive && (input.tool === 'edit' || input.tool === 'write')) { const filePath = output.args?.filePath || output.args?.path || ''; if (filePath) { const result = checkFreeze(filePath); if (result) { throw new Error( `⚠️ GStack Freeze: Edit intercepted\n` + result.message + `\nTo remove restriction, run /unfreeze.` ); } } } }, }; };