Code

/**
 * mcp/skills/deleteFiles.ts — delete_files skill
 *
 * Permanently removes files and directories.
 * Safety guards prevent deletion outside the user home directory and block
 * known OS-critical paths.  A dryRun mode lets Claude report impact first.
 *
 * IMPORTANT: Always obtain explicit user confirmation before calling this.
 *
 * Platform strategy
 * -----------------
 * Both   fs.rm({ recursive, force }) — cross-platform Node.js
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/deleteFiles.ts
 */
 
import * as fs       from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { z }         from "zod";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "delete_files",
  description:
    "Permanently deletes the specified files or directories. " +
    "ALWAYS obtain explicit user confirmation before calling. " +
    "Restricted to paths within the user home directory. " +
    "Use dryRun:true first to show the user what will be removed.",
  riskLevel:       "high",
  destructive:     true,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    paths: z
      .array(z.string().min(1))
      .min(1)
      .describe("Absolute paths of files or directories to delete."),
    dryRun: z
      .boolean()
      .optional()
      .describe(
        "When true, reports what would be deleted without actually deleting. " +
        "Default: false.",
      ),
  },
} as const;
 
// -- Safety -------------------------------------------------------------------
 
const BLOCKED_DARWIN = new Set([
  "/", "/usr", "/bin", "/sbin", "/etc", "/var",
  "/System", "/Library", "/Applications", "/private", "/private/etc",
]);
 
const BLOCKED_WIN32 = [
  "C:\\Windows",
  "C:\\Program Files",
  "C:\\Program Files (x86)",
  "C:\\ProgramData",
  "C:\\System Volume Information",
];
 
function assertSafe(target: string): void {
  if (!nodePath.isAbsolute(target)) {
    throw new Error(`Not an absolute path: ${target}`);
  }
 
  const platform = os.platform();
  const home     = os.homedir();
 
  // Must be inside home directory
  const rel = nodePath.relative(home, target);
  if (rel.startsWith("..") || nodePath.isAbsolute(rel)) {
    throw new Error(
      `Path is outside home directory (${home}): ${target}. ` +
      "delete_files only operates within the home directory.",
    );
  }
 
  // Must not BE the home directory itself
  if (rel === "") {
    throw new Error(`Refusing to delete home directory itself: ${target}`);
  }
 
  // Must not be a known OS-critical path
  if (platform === "win32") {
    for (const blocked of BLOCKED_WIN32) {
      if (target.toLowerCase().startsWith(blocked.toLowerCase())) {
        throw new Error(`Refusing to delete system path: ${target}`);
      }
    }
  } else {
    if (BLOCKED_DARWIN.has(target)) {
      throw new Error(`Refusing to delete system path: ${target}`);
    }
  }
}
 
// -- Helpers ------------------------------------------------------------------
 
function formatBytes(bytes: number): string {
  if (bytes === 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
  return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}
 
/** Recursively calculate the size of a directory or return file size. */
async function treeSize(target: string): Promise<number> {
  let stat: Awaited<ReturnType<typeof fs.stat>>;
  try {
    stat = await fs.stat(target);
  } catch {
    return 0;
  }
  if (!stat.isDirectory()) return stat.size;
 
  const children = await fs.readdir(target).catch(() => [] as string[]);
  const sizes    = await Promise.all(
    children.map((c) => treeSize(nodePath.join(target, c))),
  );
  return sizes.reduce((a, b) => a + b, 0);
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  paths,
  dryRun = false,
}: {
  paths:   string[];
  dryRun?: boolean;
}) {
  const items = await Promise.all(
    paths.map(async (p) => {
      const target = nodePath.resolve(p);
 
      // Safety check first
      try {
        assertSafe(target);
      } catch (err) {
        return {
          path:    target,
          success: false,
          error:   (err as Error).message,
        };
      }
 
      // Verify existence and guard against symlink-swap TOCTOU attacks.
      // Using lstat (not stat) so we inspect the link itself, not its target.
      // Refusing symlinks prevents an attacker from swapping a safe path for
      // a symlink pointing at a critical file between our safety check and
      // the actual fs.rm() call.
      let entryStat: Awaited<ReturnType<typeof fs.lstat>>;
      try {
        entryStat = await fs.lstat(target);
      } catch {
        return { path: target, success: false, error: "Path does not exist or is not accessible." };
      }
      if (entryStat.isSymbolicLink()) {
        return {
          path:    target,
          success: false,
          error:   "Refusing to delete symbolic links. Resolve the link target and provide the real path.",
        };
      }
 
      const sizeBytes = await treeSize(target);
 
      if (dryRun) {
        return { path: target, success: true, dryRun: true, sizeBytes, sizeHuman: formatBytes(sizeBytes) };
      }
 
      try {
        await fs.rm(target, { recursive: true, force: true });
        return { path: target, success: true, sizeBytes, sizeHuman: formatBytes(sizeBytes) };
      } catch (err) {
        return { path: target, success: false, error: (err as Error).message };
      }
    }),
  );
 
  const freedBytes = items
    .filter((i) => i.success)
    .reduce((sum, i) => sum + (i.sizeBytes ?? 0), 0);
 
  return {
    dryRun,
    deletedCount: items.filter((i) => i.success && !i.dryRun).length,
    freedBytes,
    freedHuman:   formatBytes(freedBytes),
    items,
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (require.main === module) {
  console.log("delete_files smoke test — dryRun mode only (no files actually deleted)");
  run({ paths: [nodePath.join(os.homedir(), "nonexistent-test-file-xyz.tmp")], dryRun: true })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}