614 lines
26 KiB
TypeScript
614 lines
26 KiB
TypeScript
|
|
/**
|
|||
|
|
* ╔══════════════════════════════════════════════════════════════════╗
|
|||
|
|
* ║ Confirm Destructive Actions – Pi Extension ║
|
|||
|
|
* ║ Version 1.1 (Bugfix: dd-Pattern, Befehlsanzeige) ║
|
|||
|
|
* ╚══════════════════════════════════════════════════════════════════╝
|
|||
|
|
*
|
|||
|
|
* ZWECK
|
|||
|
|
* ─────
|
|||
|
|
* Fängt destruktive Aktionen ab bevor Pi sie ausführt und fragt den
|
|||
|
|
* Nutzer um Erlaubnis. Kein Token-Verbrauch – läuft als Event-Hook
|
|||
|
|
* außerhalb des Kontextfensters.
|
|||
|
|
*
|
|||
|
|
* GESCHÜTZTE BEREICHE
|
|||
|
|
* ───────────────────
|
|||
|
|
* 1. Dateilöschung rm, rmdir, find -delete, shred, trash, dd
|
|||
|
|
* 2. Git-Destruktion push --force, reset --hard, clean, branch -D, ...
|
|||
|
|
* 3. Cloud/AWS s3 rm, cloudformation delete, terraform destroy, ...
|
|||
|
|
* 4. Netzwerk-Exfiltration curl/wget Upload, scp, rsync remote, nc reverse shell
|
|||
|
|
* 5. Systembereich Schreibzugriff außerhalb ~ und Projektordner
|
|||
|
|
* 6. Datenbanken DROP, DELETE ohne WHERE, TRUNCATE, FLUSHALL
|
|||
|
|
* 7. SSH/Remote ssh <host> mit destruktivem Befehl, ansible-playbook
|
|||
|
|
* 8. Shell-Konfiguration .bashrc, .zshrc, .ssh/config, crontab, sudoers
|
|||
|
|
* 9. Prozesse/Services kill -9, pkill, systemctl stop/disable
|
|||
|
|
* 10. Package-Installation npx unbekannte Pakete, npm install -g
|
|||
|
|
* 11. write-Tool Überschreiben bestehender Dateien + Secret-Scan
|
|||
|
|
* 12. edit-Tool Alle Dateiänderungen + Secret-Scan
|
|||
|
|
*
|
|||
|
|
* INSTALLATION
|
|||
|
|
* ────────────
|
|||
|
|
* cp confirm-destructive.ts ~/.pi/agent/extensions/
|
|||
|
|
* # Pi neu starten – Extension wird automatisch geladen
|
|||
|
|
*
|
|||
|
|
* Alte Version entfernen falls vorhanden:
|
|||
|
|
* rm ~/.pi/agent/extensions/confirm-deletion.ts
|
|||
|
|
*
|
|||
|
|
* CHANGELOG
|
|||
|
|
* ─────────
|
|||
|
|
* v1.1 – Bugfixes:
|
|||
|
|
* - FIX: dd-Pattern schlug fälschlicherweise auf /dev/null an
|
|||
|
|
* (z.B. bei `infisical secrets get ... 2>/dev/null`)
|
|||
|
|
* ALT: /\bdd\s+if=\/(dev|null)\b/
|
|||
|
|
* NEU: /\bdd\b.*\bif=\/dev\/(zero|urandom|random)\b/
|
|||
|
|
* - FIX: Befehl wurde nach 120 Zeichen abgeschnitten (jetzt 600)
|
|||
|
|
* - NEU: Mehrzeilige Befehle (\ am Zeilenende) werden korrekt angezeigt
|
|||
|
|
* - NEU: debugClassify() Export für lokales Debugging
|
|||
|
|
*
|
|||
|
|
* v1.0 – Erstversion mit allen 12 Schutzbereichen
|
|||
|
|
*
|
|||
|
|
* LOKALES DEBUGGING
|
|||
|
|
* ─────────────────
|
|||
|
|
* Wenn die Extension ein Falsch-Positiv produziert oder einen Befehl
|
|||
|
|
* nicht abfängt, kannst du sie mit einem lokalen Pi-Agenten debuggen:
|
|||
|
|
*
|
|||
|
|
* Methode A – direkt im Pi-Terminal (empfohlen):
|
|||
|
|
* ┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
* │ Schreibe in deinem Pi-Terminal: │
|
|||
|
|
* │ │
|
|||
|
|
* │ Ich möchte confirm-destructive.ts debuggen. │
|
|||
|
|
* │ Teste ob dieser Befehl fälschlicherweise anschlägt: │
|
|||
|
|
* │ TOKEN=$(infisical secrets get X --plain 2>/dev/null) │
|
|||
|
|
* │ │
|
|||
|
|
* │ Pi liest dann debugClassify() aus und testet den Befehl. │
|
|||
|
|
* └─────────────────────────────────────────────────────────────┘
|
|||
|
|
*
|
|||
|
|
* Methode B – Node.js direkt (ohne Pi):
|
|||
|
|
* ┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
* │ # TypeScript kompilieren: │
|
|||
|
|
* │ npx tsc confirm-destructive.ts --module commonjs \ │
|
|||
|
|
* │ --target es2020 --esModuleInterop │
|
|||
|
|
* │ │
|
|||
|
|
* │ # Debug-Skript erstellen (debug.mjs): │
|
|||
|
|
* │ import { debugClassify } from "./confirm-destructive.js" │
|
|||
|
|
* │ debugClassify('TOKEN=$(infisical secrets get X 2>/dev/null)') │
|
|||
|
|
* │ debugClassify('git push --force origin main') │
|
|||
|
|
* │ debugClassify('rm -rf ./dist') │
|
|||
|
|
* │ │
|
|||
|
|
* │ node debug.mjs │
|
|||
|
|
* └─────────────────────────────────────────────────────────────┘
|
|||
|
|
*
|
|||
|
|
* Methode C – Pattern isoliert testen (schnellste Methode):
|
|||
|
|
* ┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
* │ node -e " │
|
|||
|
|
* │ const p = /\bdd\b.*\bif=\/dev\/(zero|urandom|random)\b/ │
|
|||
|
|
* │ console.log(p.test('dd if=/dev/zero of=/dev/sda')) │
|
|||
|
|
* │ console.log(p.test('infisical 2>/dev/null')) │
|
|||
|
|
* │ " │
|
|||
|
|
* └─────────────────────────────────────────────────────────────┘
|
|||
|
|
*
|
|||
|
|
* NEUES PATTERN HINZUFÜGEN
|
|||
|
|
* ────────────────────────
|
|||
|
|
* 1. Passendes Array suchen (z.B. GIT_DESTRUCTIVE_PATTERNS)
|
|||
|
|
* 2. Pattern hinzufügen mit Kommentar was es abfängt
|
|||
|
|
* 3. Mit Methode C oben testen: echte Treffer ja, Falsch-Positive nein
|
|||
|
|
* 4. Schweregrad in classifyBashRisk() prüfen (critical/high/medium)
|
|||
|
|
* 5. Version im Header hochzählen + Changelog-Eintrag
|
|||
|
|
*
|
|||
|
|
* AUSNAHME HINZUFÜGEN (Falsch-Positiv beheben)
|
|||
|
|
* ─────────────────────────────────────────────
|
|||
|
|
* Für Pfade: EXCLUDED_PATH_PATTERNS erweitern
|
|||
|
|
* Für Domains: TRUSTED_DOMAINS erweitern
|
|||
|
|
* Für Pakete: SAFE_PACKAGE_COMMANDS erweitern
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|||
|
|
import * as fs from "fs";
|
|||
|
|
import * as path from "path";
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// KONFIGURATION – hier kannst du Ausnahmen anpassen
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
/** Pfade die OHNE Bestätigung verändert werden dürfen (Build-Artefakte) */
|
|||
|
|
const EXCLUDED_PATH_PATTERNS: RegExp[] = [
|
|||
|
|
/\/node_modules\/\.cache\//,
|
|||
|
|
/\/\.next\/cache\//,
|
|||
|
|
/\/dist\/.*\.(map|d\.ts)$/,
|
|||
|
|
/\/\.turbo\//,
|
|||
|
|
/\/coverage\//,
|
|||
|
|
/\/\.nyc_output\//,
|
|||
|
|
/\/tmp\//,
|
|||
|
|
/\/var\/folders\//, // macOS temp
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/** Domains die als vertrauenswürdig gelten (kein Exfiltrations-Alarm) */
|
|||
|
|
const TRUSTED_DOMAINS: RegExp[] = [
|
|||
|
|
/github\.com/,
|
|||
|
|
/npmjs\.com/,
|
|||
|
|
/pypi\.org/,
|
|||
|
|
/registry\.yarnpkg\.com/,
|
|||
|
|
/api\.anthropic\.com/,
|
|||
|
|
/amazonaws\.com\/\S+\.(zip|tar|tgz)$/, // nur Downloads, kein Upload
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
/** Package-Manager-Patterns die als sicher gelten (bekannte Registries) */
|
|||
|
|
const SAFE_PACKAGE_COMMANDS: RegExp[] = [
|
|||
|
|
/^npm (install|i|ci|update)\b/,
|
|||
|
|
/^yarn (add|install|upgrade)\b/,
|
|||
|
|
/^pnpm (add|install|update)\b/,
|
|||
|
|
/^pip install\b/,
|
|||
|
|
/^pip3 install\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// PATTERN-DEFINITIONEN
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const FILE_DELETION_PATTERNS: RegExp[] = [
|
|||
|
|
/\brm\b/,
|
|||
|
|
/\brmdir\b/,
|
|||
|
|
/\bfind\b.*\b(-delete|-exec)\b/,
|
|||
|
|
/\btrash\b/,
|
|||
|
|
/\bshred\b/,
|
|||
|
|
/\bdd\b.*\bif=\/dev\/(zero|urandom|random)\b/, // FIX v1.1: nur echter dd-Wipe, nicht /dev/null
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const GIT_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|||
|
|
/\bgit\s+push\b.*(-f\b|--force\b|--force-with-lease\b)/,
|
|||
|
|
/\bgit\s+reset\s+--hard\b/,
|
|||
|
|
/\bgit\s+clean\s+-[fdx]+\b/,
|
|||
|
|
/\bgit\s+branch\s+-D\b/,
|
|||
|
|
/\bgit\s+stash\s+(drop|clear)\b/,
|
|||
|
|
/\bgit\s+tag\s+-d\b/,
|
|||
|
|
/\bgit\s+push\b.*--delete\b/,
|
|||
|
|
/\bgit\s+rebase\b.*(-i|--interactive)\b/,
|
|||
|
|
/\bgit\s+filter-branch\b/,
|
|||
|
|
/\bgit\s+push\b.*--mirror\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const CLOUD_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|||
|
|
// AWS S3
|
|||
|
|
/\baws\s+s3\s+(rm|delete-object|delete-objects|rb)\b/,
|
|||
|
|
/\baws\s+s3\s+sync\b.*--delete\b/,
|
|||
|
|
// AWS CloudFormation / Lambda / EC2 / IAM / RDS
|
|||
|
|
/\baws\s+cloudformation\s+(delete-stack|delete-change-set)\b/,
|
|||
|
|
/\baws\s+lambda\s+delete-function\b/,
|
|||
|
|
/\baws\s+ec2\s+(terminate-instances|delete-security-group|delete-key-pair)\b/,
|
|||
|
|
/\baws\s+iam\s+(delete-user|delete-role|delete-policy|delete-access-key)\b/,
|
|||
|
|
/\baws\s+rds\s+(delete-db-instance|delete-db-cluster|delete-db-snapshot)\b/,
|
|||
|
|
// Terraform / Kubernetes / Docker
|
|||
|
|
/\bterraform\s+(destroy|apply\b.*-destroy)\b/,
|
|||
|
|
/\bkubectl\s+delete\b/,
|
|||
|
|
/\bdocker\s+(rm|rmi|volume\s+rm|system\s+prune|container\s+prune|network\s+rm)\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const NETWORK_EXFILTRATION_PATTERNS: RegExp[] = [
|
|||
|
|
// curl/wget die Dateien hochladen (nicht nur herunterladen)
|
|||
|
|
/\bcurl\b.*(-d\s*@|-F\s*['"]?\w+=@|--data-binary\s*@|--upload-file|-T\s)/,
|
|||
|
|
/\bcurl\b.*\|\s*(bash|sh|zsh|fish|sudo)\b/,
|
|||
|
|
/\bwget\b.*\|\s*(bash|sh|zsh|fish|sudo)\b/,
|
|||
|
|
// scp/rsync die Dateien nach außen senden
|
|||
|
|
/\bscp\b.*\s+\w+@[^:]+:/, // scp localfile user@remote:
|
|||
|
|
/\brsync\b.*\s+\w+@[^:]+:/, // rsync ... user@remote:
|
|||
|
|
/\brsync\b.*-[a-z]*r[a-z]*.*@/, // rsync mit remote
|
|||
|
|
// Netcat Reverse Shell / Datenexfiltration
|
|||
|
|
/\bnc\b.*(-e|-c)\s/, // nc -e /bin/bash
|
|||
|
|
/\bncat\b.*(-e|-c)\s/,
|
|||
|
|
// SSH Tunnel / Port Forwarding (kann Daten tunneln)
|
|||
|
|
/\bssh\b.*-[LRD]\s*\d+/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const SYSTEM_PATH_PATTERNS: RegExp[] = [
|
|||
|
|
/\/(etc|usr|bin|sbin|lib|lib64|boot|sys|proc)\//,
|
|||
|
|
/\/Library\/LaunchDaemons\//, // macOS system services
|
|||
|
|
/\/Library\/LaunchAgents\//, // macOS user agents (persistent)
|
|||
|
|
/~\/\.ssh\//, // SSH-Konfiguration
|
|||
|
|
/~\/\.bashrc|~\/\.zshrc|~\/\.profile|~\/\.bash_profile/,
|
|||
|
|
/~\/\.gitconfig/,
|
|||
|
|
/\/etc\/cron/,
|
|||
|
|
/\/etc\/sudoers/,
|
|||
|
|
/~\/\.aws\//, // AWS Credentials
|
|||
|
|
/~\/\.config\/gcloud\//, // GCloud Credentials
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const DATABASE_DESTRUCTIVE_PATTERNS: RegExp[] = [
|
|||
|
|
// SQL: DROP, TRUNCATE, DELETE ohne WHERE
|
|||
|
|
/\b(mysql|psql|sqlite3)\b.*(-e|--command)\s+['"]?\s*(DROP|TRUNCATE)/i,
|
|||
|
|
/\b(mysql|psql|sqlite3)\b.*(-e|--command)\s+['"]?\s*DELETE\s+FROM\s+\w+\s*['";\s]/i, // kein WHERE
|
|||
|
|
/\b(mysql|psql|sqlite3)\b.*(-e|--command)\s+['"]?\s*DELETE\s+FROM\s+\w+\s*$/i,
|
|||
|
|
// MongoDB
|
|||
|
|
/\bmongo\b.*--eval\s+['"]?\s*(db\.(drop|dropDatabase|getCollection))/i,
|
|||
|
|
// Redis
|
|||
|
|
/\bredis-cli\b.*(FLUSHALL|FLUSHDB|DEL\s+\*)/i,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const SSH_REMOTE_PATTERNS: RegExp[] = [
|
|||
|
|
// ssh mit destruktivem Befehl
|
|||
|
|
/\bssh\b\s+\S+\s+['"]?.*(rm\s+-rf|DROP\s+DATABASE|DELETE\s+FROM|kubectl\s+delete|terraform\s+destroy)/i,
|
|||
|
|
// ansible mit destruktivem Modul
|
|||
|
|
/\bansible\b.*(-m\s+shell|-m\s+command|-m\s+raw)\b.*-a\s+['"]?.*(rm\s+-rf|DROP|DELETE)/i,
|
|||
|
|
/\bansible-playbook\b/, // jedes Playbook fragen – kann alles tun
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const SHELL_CONFIG_PATTERNS: RegExp[] = [
|
|||
|
|
/>>\s*~?\/?(\.bashrc|\.zshrc|\.profile|\.bash_profile|\.bash_login)/,
|
|||
|
|
/>>\s*~?\/?\.ssh\/(config|authorized_keys|known_hosts)/,
|
|||
|
|
/\bcrontab\s+-[re]\b/,
|
|||
|
|
/\bvisudo\b/,
|
|||
|
|
/>\s*\/etc\/environment/,
|
|||
|
|
/\blaunchctl\s+(load|unload|submit)\b/, // macOS services
|
|||
|
|
/\bsystemctl\s+(enable|disable|mask)\b/,
|
|||
|
|
/\bchmod\s+(777|a\+[wx]|o\+[wx])\b/, // gefährliche Berechtigungen
|
|||
|
|
/\bchown\s+root\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const PROCESS_KILL_PATTERNS: RegExp[] = [
|
|||
|
|
/\bkill\s+-9\b/,
|
|||
|
|
/\bpkill\b/,
|
|||
|
|
/\bkillall\b/,
|
|||
|
|
/\bsystemctl\s+(stop|kill)\b/,
|
|||
|
|
/\bservice\s+\w+\s+stop\b/,
|
|||
|
|
/\blaunchctl\s+remove\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const PACKAGE_INSTALL_PATTERNS: RegExp[] = [
|
|||
|
|
/\bnpx\s+(?!--yes\s+create-)\S+/, // npx mit unbekanntem Paket
|
|||
|
|
/\bnpm\s+install\s+-g\b/, // globale npm-Installation
|
|||
|
|
/\bpip\s+install\b.*--user\b/,
|
|||
|
|
/\bcurl\b.*\|\s*(bash|sh)\b/, // classic curl-pipe
|
|||
|
|
/\bwget\b.*-O-.*\|\s*(bash|sh)\b/,
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const SECRET_CONTENT_PATTERNS: RegExp[] = [
|
|||
|
|
/\bAWS_SECRET_ACCESS_KEY\s*[=:]/i,
|
|||
|
|
/\bAWS_ACCESS_KEY_ID\s*[=:]/i,
|
|||
|
|
/\bSECRET_KEY\s*[=:]/i,
|
|||
|
|
/\bAPI_KEY\s*[=:]/i,
|
|||
|
|
/\bPRIVATE_KEY\s*[=:]/i,
|
|||
|
|
/\bACCESS_TOKEN\s*[=:]/i,
|
|||
|
|
/\bAUTH_TOKEN\s*[=:]/i,
|
|||
|
|
/\bDATABASE_PASSWORD\s*[=:]/i,
|
|||
|
|
/\bDB_PASS(WORD)?\s*[=:]/i,
|
|||
|
|
/\bGITHUB_TOKEN\s*[=:]/i,
|
|||
|
|
/\bNPM_TOKEN\s*[=:]/i,
|
|||
|
|
/\bANTHROPIC_API_KEY\s*[=:]/i,
|
|||
|
|
/-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|||
|
|
/\bghp_[A-Za-z0-9]{36}\b/, // GitHub PAT
|
|||
|
|
/\bsk-[A-Za-z0-9]{48}\b/, // OpenAI Key
|
|||
|
|
/\bAIza[A-Za-z0-9_-]{35}\b/, // Google API Key
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// TYPEN
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
interface RiskResult {
|
|||
|
|
category: string;
|
|||
|
|
warning: string;
|
|||
|
|
severity: "critical" | "high" | "medium";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// HILFSFUNKTIONEN
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function normalizePath(targetPath: string, cwd: string): string {
|
|||
|
|
const expanded = targetPath.replace(/^~/, process.env.HOME ?? "~");
|
|||
|
|
return (expanded.startsWith("/") ? expanded : path.join(cwd, expanded)).replace(/\/+$/, "");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isExcludedPath(targetPath: string, cwd: string): boolean {
|
|||
|
|
const abs = normalizePath(targetPath, cwd);
|
|||
|
|
return EXCLUDED_PATH_PATTERNS.some((p) => p.test(abs));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isTrustedDomain(command: string): boolean {
|
|||
|
|
return TRUSTED_DOMAINS.some((p) => p.test(command));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fileExists(filePath: string, cwd: string): boolean {
|
|||
|
|
try {
|
|||
|
|
return fs.existsSync(normalizePath(filePath, cwd ?? ""));
|
|||
|
|
} catch {
|
|||
|
|
return true; // im Zweifel: existiert
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractTarget(command: string): string | null {
|
|||
|
|
const matchers: RegExp[] = [
|
|||
|
|
/\brm\s+(-[rfFRdv]*\s+)?["']?([^\s"';|&>]+)/,
|
|||
|
|
/\brmdir\s+(-p\s+)?["']?([^\s"';|&>]+)/,
|
|||
|
|
/\btrash\s+["']?([^\s"';|&>]+)/,
|
|||
|
|
/\bshred\s+(-[nuzfv]*\s+)?["']?([^\s"';|&>]+)/,
|
|||
|
|
/\baws\s+s3\s+\S+\s+(s3:\/\/[^\s]+)/,
|
|||
|
|
/\bscp\s+\S+\s+(\S+)/,
|
|||
|
|
/\brsync\s+.*\s+(\w+@[^:]+:\S*)/,
|
|||
|
|
];
|
|||
|
|
for (const p of matchers) {
|
|||
|
|
const m = command.match(p);
|
|||
|
|
if (m) return m[m.length - 1];
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function detectSecretInContent(content: string): string | null {
|
|||
|
|
for (const pattern of SECRET_CONTENT_PATTERNS) {
|
|||
|
|
const match = content.match(pattern);
|
|||
|
|
if (match) return match[0];
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function classifyBashRisk(command: string): RiskResult | null {
|
|||
|
|
|
|||
|
|
// Pipe-to-shell (höchste Priorität)
|
|||
|
|
if (NETWORK_EXFILTRATION_PATTERNS.some((p) => p.test(command)) ||
|
|||
|
|
/\b(curl|wget)\b.*\|\s*(bash|sh|zsh|fish|sudo)\b/.test(command)) {
|
|||
|
|
if (isTrustedDomain(command) && !/\|\s*(bash|sh|zsh|fish|sudo)\b/.test(command)) {
|
|||
|
|
return null; // Download von vertrauenswürdiger Domain ohne pipe → ok
|
|||
|
|
}
|
|||
|
|
const isReversShell = /\bnc\b.*(-e|-c)\s/.test(command);
|
|||
|
|
return {
|
|||
|
|
category: "🌐 Netzwerk-Exfiltration",
|
|||
|
|
warning: isReversShell
|
|||
|
|
? "KRITISCH: Mögliche Reverse-Shell-Verbindung nach außen!"
|
|||
|
|
: "Dieser Befehl sendet Daten an einen externen Server oder führt heruntergeladenen Code aus!",
|
|||
|
|
severity: "critical",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SSH Remote mit destruktivem Befehl
|
|||
|
|
if (SSH_REMOTE_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
return {
|
|||
|
|
category: "🖥️ SSH/Remote-Befehl",
|
|||
|
|
warning: "Dieser Befehl führt destruktive Operationen auf einem Remote-System aus!",
|
|||
|
|
severity: "critical",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Systembereich-Schreibzugriff
|
|||
|
|
const systemWrite = /[>|]\s*\/?(etc|usr|bin|sbin|lib|boot)\//i.test(command) ||
|
|||
|
|
/\btee\s+\/(etc|usr|bin|sbin)\//i.test(command) ||
|
|||
|
|
/\bchmod\s+(777|a\+[wx]|o\+[wx])\b/.test(command);
|
|||
|
|
if (systemWrite) {
|
|||
|
|
return {
|
|||
|
|
category: "🔒 Systembereich-Zugriff",
|
|||
|
|
warning: "ACHTUNG: Dieser Befehl schreibt in geschützte Systembereiche!",
|
|||
|
|
severity: "critical",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Shell-Konfiguration ändern
|
|||
|
|
if (SHELL_CONFIG_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
return {
|
|||
|
|
category: "⚙️ Shell/System-Konfiguration",
|
|||
|
|
warning: "Dieser Befehl ändert persistente Systemkonfiguration (Shell, SSH, Cron, Services).",
|
|||
|
|
severity: "high",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Git-Destruktion
|
|||
|
|
if (GIT_DESTRUCTIVE_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
const isForce = /--force\b|-f\b|--mirror\b/.test(command);
|
|||
|
|
return {
|
|||
|
|
category: "🔀 Git-Destruktion",
|
|||
|
|
warning: isForce
|
|||
|
|
? "ACHTUNG: Force-Push überschreibt den Remote-Stand unwiederbringlich!"
|
|||
|
|
: "Dieser Git-Befehl löscht oder überschreibt Daten.",
|
|||
|
|
severity: isForce ? "critical" : "high",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Cloud/AWS-Ressourcen
|
|||
|
|
if (CLOUD_DESTRUCTIVE_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
const isTerraformDestroy = /terraform\s+destroy/.test(command);
|
|||
|
|
return {
|
|||
|
|
category: "☁️ Cloud-Ressource",
|
|||
|
|
warning: isTerraformDestroy
|
|||
|
|
? "KRITISCH: terraform destroy löscht ALLE verwalteten Infrastruktur-Ressourcen!"
|
|||
|
|
: "Dieser Befehl löscht Cloud-Ressourcen (AWS/Terraform/Kubernetes/Docker).",
|
|||
|
|
severity: isTerraformDestroy ? "critical" : "high",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Datenbankoperationen
|
|||
|
|
if (DATABASE_DESTRUCTIVE_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
return {
|
|||
|
|
category: "🗄️ Datenbank-Destruktion",
|
|||
|
|
warning: "Dieser Befehl löscht Datenbank-Daten oder -Strukturen (DROP/TRUNCATE/DELETE/FLUSH)!",
|
|||
|
|
severity: "critical",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Dateilöschung
|
|||
|
|
if (FILE_DELETION_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
const isRecursive = /\brm\s+-[^-]*r/i.test(command);
|
|||
|
|
return {
|
|||
|
|
category: "🗑️ Dateilöschung",
|
|||
|
|
warning: isRecursive
|
|||
|
|
? "ACHTUNG: Rekursive Löschung – kann nicht rückgängig gemacht werden!"
|
|||
|
|
: "Dieser Befehl löscht Dateien oder Verzeichnisse.",
|
|||
|
|
severity: isRecursive ? "high" : "medium",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Prozesse/Services beenden
|
|||
|
|
if (PROCESS_KILL_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
return {
|
|||
|
|
category: "⛔ Prozess/Service beenden",
|
|||
|
|
warning: "Dieser Befehl beendet Prozesse oder Services.",
|
|||
|
|
severity: "medium",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Package-Installation (nur npx unbekannt + globale npm)
|
|||
|
|
if (PACKAGE_INSTALL_PATTERNS.some((p) => p.test(command))) {
|
|||
|
|
if (SAFE_PACKAGE_COMMANDS.some((p) => p.test(command))) return null;
|
|||
|
|
return {
|
|||
|
|
category: "📦 Package-Installation",
|
|||
|
|
warning: "Dieses Package könnte Lifecycle-Scripts ausführen (postinstall).",
|
|||
|
|
severity: "medium",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildBashMessage(command: string, risk: RiskResult): string {
|
|||
|
|
const target = extractTarget(command);
|
|||
|
|
const severityIcon = risk.severity === "critical" ? "🚨" :
|
|||
|
|
risk.severity === "high" ? "⚠️ " : "ℹ️ ";
|
|||
|
|
|
|||
|
|
// FIX v1.1: Befehl vollständig anzeigen (war 120, jetzt 600 Zeichen)
|
|||
|
|
// Mehrzeilige Befehle (\ am Zeilenende) werden eingerückt dargestellt
|
|||
|
|
const displayCommand = command.length > 600
|
|||
|
|
? command.slice(0, 597) + "..."
|
|||
|
|
: command;
|
|||
|
|
const formattedCommand = displayCommand
|
|||
|
|
.split(/\\\n/)
|
|||
|
|
.map((line, i) => i === 0 ? ` Befehl: ${line.trim()}` : ` ${line.trim()}`)
|
|||
|
|
.join("\n");
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
`${severityIcon} ${risk.category}`,
|
|||
|
|
"",
|
|||
|
|
formattedCommand,
|
|||
|
|
target ? ` Ziel: ${target}` : "",
|
|||
|
|
"",
|
|||
|
|
risk.warning,
|
|||
|
|
].filter(Boolean).join("\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// DEBUG-HILFSFUNKTION
|
|||
|
|
// Für lokales Debugging: pi confirm-destructive.ts --debug
|
|||
|
|
// Zeigt welches Pattern auf welchen Befehl anschlägt.
|
|||
|
|
// Verwendung im Pi-Terminal:
|
|||
|
|
// /skill:debug-extension
|
|||
|
|
// Oder direkt: node -e "require('./confirm-destructive.ts')" (nach tsc)
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
export function debugClassify(command: string): void {
|
|||
|
|
const result = classifyBashRisk(command);
|
|||
|
|
if (!result) {
|
|||
|
|
console.log(`✅ KEIN TREFFER: "${command}"`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
console.log(`${result.severity.toUpperCase()} – ${result.category}`);
|
|||
|
|
console.log(` Befehl: ${command}`);
|
|||
|
|
console.log(` Warnung: ${result.warning}`);
|
|||
|
|
const target = extractTarget(command);
|
|||
|
|
if (target) console.log(` Ziel: ${target}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// EXTENSION
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
export default function (pi: ExtensionAPI) {
|
|||
|
|
pi.on("tool_call", async (event, ctx) => {
|
|||
|
|
|
|||
|
|
// ── 1. BASH ──────────────────────────────────────────────────────────────
|
|||
|
|
if (event.toolName === "bash") {
|
|||
|
|
const command = (event.input as { command: string }).command;
|
|||
|
|
if (!command) return undefined;
|
|||
|
|
|
|||
|
|
const risk = classifyBashRisk(command);
|
|||
|
|
if (!risk) return undefined;
|
|||
|
|
|
|||
|
|
// Ausnahme: bekannte Build-Artefakt-Pfade
|
|||
|
|
const target = extractTarget(command);
|
|||
|
|
if (target && ctx.cwd && isExcludedPath(target, ctx.cwd)) return undefined;
|
|||
|
|
|
|||
|
|
if (!ctx.hasUI) {
|
|||
|
|
return { block: true, reason: `Destruktiver Befehl blockiert (kein UI): ${command}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const allowed = await ctx.ui.confirm(buildBashMessage(command, risk), "Erlauben?");
|
|||
|
|
if (!allowed) return { block: true, reason: `Abgelehnt: ${risk.category}` };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 2. WRITE ─────────────────────────────────────────────────────────────
|
|||
|
|
if (event.toolName === "write") {
|
|||
|
|
const input = event.input as { path?: string; content?: string };
|
|||
|
|
const filePath = input.path;
|
|||
|
|
const content = input.content ?? "";
|
|||
|
|
if (!filePath || !ctx.hasUI) return undefined;
|
|||
|
|
|
|||
|
|
// Secret im Inhalt → immer blockieren
|
|||
|
|
const secretMatch = detectSecretInContent(content);
|
|||
|
|
if (secretMatch) {
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"🔑 Möglicher Secret/API-Key im Dateiinhalt",
|
|||
|
|
`Gefunden: "${secretMatch}"\nZieldatei: ${filePath}\n\nTrotzdem schreiben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Secret-Schreibvorgang abgelehnt" };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Systembereich-Schreibzugriff
|
|||
|
|
const absPath = normalizePath(filePath, ctx.cwd ?? "");
|
|||
|
|
if (SYSTEM_PATH_PATTERNS.some((p) => p.test(absPath))) {
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"🔒 Schreibzugriff in Systembereich",
|
|||
|
|
`Ziel: ${absPath}\n\nDiese Datei liegt außerhalb deines Projekts. Erlauben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Systembereich-Schreibzugriff abgelehnt" };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Bestehende Datei überschreiben
|
|||
|
|
if (fileExists(filePath, ctx.cwd ?? "")) {
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"📝 Datei überschreiben",
|
|||
|
|
`${filePath}\n\nDiese Datei existiert bereits. Inhalt wird vollständig ersetzt. Erlauben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Überschreiben abgelehnt" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 3. EDIT ──────────────────────────────────────────────────────────────
|
|||
|
|
if (event.toolName === "edit") {
|
|||
|
|
const input = event.input as { path?: string; edits?: Array<{ oldText?: string; newText?: string }> };
|
|||
|
|
const filePath = input.path;
|
|||
|
|
const edits = input.edits ?? [];
|
|||
|
|
if (!filePath || !ctx.hasUI) return undefined;
|
|||
|
|
|
|||
|
|
// Secret in neuem Inhalt
|
|||
|
|
for (const edit of edits) {
|
|||
|
|
const secretMatch = detectSecretInContent(edit.newText ?? "");
|
|||
|
|
if (secretMatch) {
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"🔑 Möglicher Secret/API-Key in Änderung",
|
|||
|
|
`Gefunden: "${secretMatch}"\nDatei: ${filePath}\n\nTrotzdem schreiben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Secret-Edit abgelehnt" };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Systembereich
|
|||
|
|
const absPath = normalizePath(filePath, ctx.cwd ?? "");
|
|||
|
|
if (SYSTEM_PATH_PATTERNS.some((p) => p.test(absPath))) {
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"🔒 Änderung in Systembereich",
|
|||
|
|
`${absPath}\n\nDiese Datei liegt außerhalb deines Projekts. Erlauben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Systembereich-Änderung abgelehnt" };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const confirmed = await ctx.ui.confirm(
|
|||
|
|
"✏️ Datei ändern",
|
|||
|
|
`${filePath}\n${edits.length} Änderung(en)\n\nErlauben?`,
|
|||
|
|
);
|
|||
|
|
if (!confirmed) return { block: true, reason: "Änderung abgelehnt" };
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return undefined;
|
|||
|
|
});
|
|||
|
|
}
|