personality: Harden filesystem ops

Amolith created

Wrap all filesystem calls (readdirSync, readFileSync, writeFileSync,
unlinkSync) in try/catch with graceful fallbacks — return empty/null
instead of crashing the extension.

Validate the persisted personality name against listPersonalities()
before loading it. A tampered persistence file containing a path like
../../etc/something would previously be passed straight to readFileSync;
now it is rejected if not in the available list.

Change summary

packages/personality/src/index.ts | 58 +++++++++++++++++++++++++-------
1 file changed, 45 insertions(+), 13 deletions(-)

Detailed changes

packages/personality/src/index.ts 🔗

@@ -49,29 +49,56 @@ function listPersonalities(): string[] {
 	const dir = getPersonalitiesDir();
 	if (!fs.existsSync(dir)) return [];
 
-	return fs
-		.readdirSync(dir)
-		.filter((f) => f.endsWith(".md"))
-		.map((f) => f.slice(0, -3))
-		.sort();
+	try {
+		return fs
+			.readdirSync(dir)
+			.filter((f) => f.endsWith(".md"))
+			.map((f) => f.slice(0, -3))
+			.sort();
+	} catch (err) {
+		console.error(`Failed to list personalities: ${err}`);
+		return [];
+	}
 }
 
-/** Read the persisted active personality name, or null if none. */
+/**
+ * Read the persisted active personality name, or null if none.
+ * Validates the name against available personalities to prevent
+ * path traversal via a tampered persistence file.
+ */
 function readActivePersonality(): string | null {
 	const filePath = getActivePersonalityPath();
 	if (!fs.existsSync(filePath)) return null;
 
-	const name = fs.readFileSync(filePath, "utf-8").trim();
-	return name || null;
+	try {
+		const name = fs.readFileSync(filePath, "utf-8").trim();
+		if (!name) return null;
+
+		// Reject names that could escape the personalities directory
+		const available = listPersonalities();
+		if (!available.includes(name)) {
+			console.error(`Persisted personality "${name}" not in available list, ignoring`);
+			return null;
+		}
+
+		return name;
+	} catch (err) {
+		console.error(`Failed to read active personality: ${err}`);
+		return null;
+	}
 }
 
 /** Persist the active personality name (or remove the file to clear). */
 function writeActivePersonality(name: string | null): void {
 	const filePath = getActivePersonalityPath();
-	if (name) {
-		fs.writeFileSync(filePath, `${name}\n`, "utf-8");
-	} else if (fs.existsSync(filePath)) {
-		fs.unlinkSync(filePath);
+	try {
+		if (name) {
+			fs.writeFileSync(filePath, `${name}\n`, "utf-8");
+		} else if (fs.existsSync(filePath)) {
+			fs.unlinkSync(filePath);
+		}
+	} catch (err) {
+		console.error(`Failed to write active personality: ${err}`);
 	}
 }
 
@@ -79,7 +106,12 @@ function writeActivePersonality(name: string | null): void {
 function readPersonalityContent(name: string): string | null {
 	const filePath = path.join(getPersonalitiesDir(), `${name}.md`);
 	if (!fs.existsSync(filePath)) return null;
-	return fs.readFileSync(filePath, "utf-8");
+	try {
+		return fs.readFileSync(filePath, "utf-8");
+	} catch (err) {
+		console.error(`Failed to read personality "${name}": ${err}`);
+		return null;
+	}
 }
 
 const PERSONALITY_TAG_OPEN =