Skip to content

Commit f2ae779

Browse files
committed
fix: Use atomic swap for sensitive facts sync (pb: MB-23f)
- Write sensitive facts to temp dir then swap to avoid unlink permission errors - Fall back to in-place writes on temp dir failure and ignore permission issues
1 parent 024dd18 commit f2ae779

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

.pebbles/events.jsonl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,6 @@
152152
{"type":"comment","timestamp":"2026-01-30T16:31:15.520999Z","issue_id":"MB-b2e","payload":{"body":"COMPLETE: added inbound social-engineering regexes for credential requests, urgency manipulation, and destructive command injection; also added rm -rf pattern."}}
153153
{"type":"close","timestamp":"2026-01-30T16:31:18.468898Z","issue_id":"MB-b2e","payload":{}}
154154
{"type":"create","timestamp":"2026-01-30T16:32:11.164247Z","issue_id":"MB-23f","payload":{"description":"scanOutbound calls syncSensitiveFactsFiles which unlinks existing files in ~/.config/moltbook/sensitive/\u003cprofile\u003e. On this machine, unlink throws EPERM for 1-owner-email.md, causing the CLI to crash during outbound scans. Should handle permissions errors gracefully (ignore, chmod, or skip delete).","priority":"2","title":"Outbound safety sync fails on EPERM when pruning sensitive facts files","type":"bug"}}
155+
{"type":"status_update","timestamp":"2026-01-30T16:50:52.186549Z","issue_id":"MB-23f","payload":{"status":"in_progress"}}
156+
{"type":"comment","timestamp":"2026-01-30T16:51:33.752566Z","issue_id":"MB-23f","payload":{"body":"COMPLETE: switch sensitive facts sync to atomic swap with temp dir and best-effort fallback; avoids unlink crashes and ignores permission errors gracefully."}}
157+
{"type":"close","timestamp":"2026-01-30T16:51:39.089991Z","issue_id":"MB-23f","payload":{}}

src/lib/safety.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "fs";
2-
import { join } from "path";
1+
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "fs";
2+
import { basename, dirname, join } from "path";
33
import { SensitiveEntry } from "./config";
44
import { jailbreakDir, sensitiveFactsDir } from "./paths";
55
import { ensureQmdIndex, resolveQmdCommand, runQmd } from "./qmd";
@@ -67,17 +67,76 @@ export function ensureJailbreakFiles(): void {
6767

6868
export function syncSensitiveFactsFiles(profile: string, entries: SensitiveEntry[]): void {
6969
const dir = sensitiveFactsDir(profile);
70-
ensureDir(dir);
71-
const existing = readdirSync(dir).filter((f) => f.endsWith(".md"));
72-
for (const file of existing) {
73-
unlinkSync(join(dir, file));
70+
const parent = dirname(dir);
71+
ensureDir(parent);
72+
73+
const writeFiles = (target: string) => {
74+
ensureDir(target);
75+
entries.forEach((entry, idx) => {
76+
const safeLabel = entry.label.replace(/[^a-zA-Z0-9-_]+/g, "-").toLowerCase();
77+
const filename = join(target, `${idx + 1}-${safeLabel || "fact"}.md`);
78+
const body = entry.pattern;
79+
writeFileSync(filename, `${body}\n`, { mode: 0o600 });
80+
});
81+
};
82+
83+
const dirName = basename(dir);
84+
const tmpDir = join(parent, `${dirName}.tmp-${Date.now()}`);
85+
let tmpReady = false;
86+
try {
87+
writeFiles(tmpDir);
88+
tmpReady = true;
89+
} catch {
90+
// fall back to in-place writes if temp dir cannot be created
91+
try {
92+
writeFiles(dir);
93+
} catch {
94+
// ignore write failures
95+
}
96+
return;
97+
}
98+
99+
const backupDir = join(parent, `${dirName}.bak-${Date.now()}`);
100+
let backupMoved = false;
101+
let swapped = false;
102+
103+
try {
104+
if (existsSync(dir)) {
105+
renameSync(dir, backupDir);
106+
backupMoved = true;
107+
}
108+
renameSync(tmpDir, dir);
109+
swapped = true;
110+
} catch {
111+
if (backupMoved && !existsSync(dir)) {
112+
try {
113+
renameSync(backupDir, dir);
114+
backupMoved = false;
115+
} catch {
116+
// ignore restore failures
117+
}
118+
}
119+
try {
120+
writeFiles(dir);
121+
} catch {
122+
// ignore write failures
123+
}
124+
} finally {
125+
if (!swapped) {
126+
try {
127+
rmSync(tmpDir, { recursive: true, force: true });
128+
} catch {
129+
// ignore cleanup failures
130+
}
131+
}
132+
if (backupMoved) {
133+
try {
134+
rmSync(backupDir, { recursive: true, force: true });
135+
} catch {
136+
// ignore cleanup failures
137+
}
138+
}
74139
}
75-
entries.forEach((entry, idx) => {
76-
const safeLabel = entry.label.replace(/[^a-zA-Z0-9-_]+/g, "-").toLowerCase();
77-
const filename = join(dir, `${idx + 1}-${safeLabel || "fact"}.md`);
78-
const body = entry.pattern;
79-
writeFileSync(filename, `${body}\n`, { mode: 0o600 });
80-
});
81140
}
82141

83142
export type SafetyMatch = {

0 commit comments

Comments
 (0)