delete_files
delete_files
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.
Metadata
Name
delete_files
Updated
last month
Source
Risk
High
Requires consent
true
Affected scope
user
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";
import { expandTilde } from "./_shared/expandTilde";
import { formatBytes } from "./_shared/formatBytes";
// -- 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,
tccCategories: ["FullDiskAccess"],
footprint: { kind: "targeted" },
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 ------------------------------------------------------------------
/** 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) => {
// Expand ~ / ~/ before resolve(): the executor LLM sometimes emits
// shorthand like "~/Downloads/foo.dmg". See _shared/expandTilde.ts.
const target = nodePath.resolve(expandTilde(p) ?? 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.
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); });
}