opencode-code-agent/plugin/gstack-guardian.js

147 lines
5.1 KiB
JavaScript

// 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.`
);
}
}
}
},
};
};