147 lines
5.1 KiB
JavaScript
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.`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|