@lifeaitools/clauth 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,13 @@ import { appendFile, readdir, readFile, writeFile, rm, mkdir, stat, rename, cp }
21
21
  import fg from "fast-glob";
22
22
  import { rgPath } from "@vscode/ripgrep";
23
23
  import { createStudioDebugRuntime } from "../studio-debug.js";
24
+ import { writeCredentialWithRecovery } from "../recovery.js";
25
+ import * as fsGit from "../lib/fs-git.js";
26
+ import {
27
+ getWatchdogStatuses,
28
+ readWatchdogEvents,
29
+ restartWatchdogService,
30
+ } from "../watchdog-registry.js";
24
31
 
25
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
33
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
@@ -309,9 +316,14 @@ function createRotationEngine(password, machineHash, logFile) {
309
316
  const result = await config.rotate(currentKey);
310
317
  if (!result.newKey) throw new Error("Rotation returned no new key");
311
318
 
312
- // Write new key to vault
313
- const { token: wt, timestamp: wts } = deriveToken(password, machineHash);
314
- const writeResult = await api.write(password, machineHash, wt, wts, serviceName, result.newKey);
319
+ // Write new key to vault after preserving an encrypted recovery snapshot.
320
+ const { result: writeResult } = await writeCredentialWithRecovery({
321
+ password,
322
+ machineHash,
323
+ service: serviceName,
324
+ value: result.newKey,
325
+ logFile,
326
+ });
315
327
  if (writeResult.error) throw new Error(`Write failed: ${writeResult.error}`);
316
328
 
317
329
  // Update expiry in state
@@ -412,6 +424,26 @@ const STAGED_PID_FILE = path.join(os.tmpdir(), "clauth-serve-staged.pid");
412
424
  const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
413
425
  const LIVE_PORT = 52437;
414
426
  const STAGED_PORT = 52438;
427
+ const WRITE_TOKEN_BYTES = 32;
428
+ const WRITE_TOKEN_TTL_MS = 10 * 60 * 1000;
429
+
430
+ function makeWriteToken() {
431
+ return {
432
+ token: crypto.randomBytes(WRITE_TOKEN_BYTES).toString("base64url"),
433
+ expiresAt: Date.now() + WRITE_TOKEN_TTL_MS,
434
+ };
435
+ }
436
+
437
+ function validateWriteToken(req, writeSession) {
438
+ if (!writeSession?.token || Date.now() > writeSession.expiresAt) return false;
439
+ const header = req.headers["x-clauth-write-token"] || req.headers.authorization;
440
+ const token = Array.isArray(header) ? header[0] : header;
441
+ if (!token) return false;
442
+ const supplied = String(token).startsWith("Bearer ") ? String(token).slice(7) : String(token);
443
+ const a = Buffer.from(supplied);
444
+ const b = Buffer.from(writeSession.token);
445
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
446
+ }
415
447
 
416
448
  // ── PID helpers ──────────────────────────────────────────────
417
449
  function readPid() {
@@ -968,6 +1000,13 @@ function renderSetPanel(name) {
968
1000
  }
969
1001
 
970
1002
  // ── Boot: check lock state ──────────────────
1003
+ let writeToken = null;
1004
+
1005
+ function writeHeaders(extra) {
1006
+ if (!writeToken) throw new Error("Write access requires password unlock in this browser session.");
1007
+ return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
1008
+ }
1009
+
971
1010
  async function boot() {
972
1011
  try {
973
1012
  const ping = await fetch(BASE + "/ping").then(r => r.json());
@@ -1090,6 +1129,7 @@ async function unlock() {
1090
1129
  throw new Error(r.error + rem);
1091
1130
  }
1092
1131
 
1132
+ writeToken = r.write_token || null;
1093
1133
  input.value = "";
1094
1134
  const ping = await fetch(BASE + "/ping").then(r => r.json());
1095
1135
  showMain(ping);
@@ -1107,6 +1147,7 @@ async function unlock() {
1107
1147
  // ── Lock ────────────────────────────────────
1108
1148
  async function lockVault() {
1109
1149
  const r = await fetch(BASE + "/lock", { method: "POST" }).then(r => r.json()).catch(() => ({}));
1150
+ writeToken = null;
1110
1151
  showLockScreen(r.hard_locked || false);
1111
1152
  }
1112
1153
 
@@ -1398,7 +1439,7 @@ async function rotateKey(name) {
1398
1439
  const rotBtn = document.getElementById("rotbtn-" + name);
1399
1440
  if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
1400
1441
  try {
1401
- const r = await fetch(BASE + "/rotate/" + name, { method: "POST" }).then(r => r.json());
1442
+ const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1402
1443
  if (r.ok) {
1403
1444
  if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
1404
1445
  loadExpiry(); // refresh badges
@@ -1419,7 +1460,7 @@ async function setExpiry(name) {
1419
1460
  try {
1420
1461
  await fetch(BASE + "/set-expiry/" + name, {
1421
1462
  method: "POST",
1422
- headers: { "Content-Type": "application/json" },
1463
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1423
1464
  body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
1424
1465
  });
1425
1466
  loadExpiry();
@@ -1487,7 +1528,7 @@ async function saveProject(name) {
1487
1528
  try {
1488
1529
  const r = await fetch(BASE + "/update-service", {
1489
1530
  method: "POST",
1490
- headers: { "Content-Type": "application/json" },
1531
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1491
1532
  body: JSON.stringify({ service: name, project: project || "" })
1492
1533
  }).then(r => r.json());
1493
1534
  if (r.locked) { showLockScreen(); return; }
@@ -1532,7 +1573,7 @@ async function saveLabel(name) {
1532
1573
  try {
1533
1574
  const r = await fetch(BASE + "/update-service", {
1534
1575
  method: "POST",
1535
- headers: { "Content-Type": "application/json" },
1576
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1536
1577
  body: JSON.stringify({ service: name, label: newLabel })
1537
1578
  }).then(r => r.json());
1538
1579
  if (r.locked) { showLockScreen(false); return; }
@@ -1556,7 +1597,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
1556
1597
  async function deleteService(name) {
1557
1598
  if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
1558
1599
  try {
1559
- const r = await fetch(BASE + "/delete/" + name, { method: "POST" }).then(r => r.json());
1600
+ const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
1560
1601
  if (r.locked) { showLockScreen(false); return; }
1561
1602
  if (r.error) throw new Error(r.error);
1562
1603
  loadServices();
@@ -1612,15 +1653,15 @@ async function saveKey(name) {
1612
1653
  value = JSON.stringify(obj);
1613
1654
  } else {
1614
1655
  const input = document.getElementById("set-input-" + name);
1615
- value = input ? input.value.trim() : "";
1616
- if (!value) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
1656
+ value = input ? input.value : "";
1657
+ if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
1617
1658
  }
1618
1659
 
1619
1660
  msg.className = "set-msg"; msg.textContent = "Saving…";
1620
1661
  try {
1621
1662
  const r = await fetch(BASE + "/set/" + name, {
1622
1663
  method: "POST",
1623
- headers: { "Content-Type": "application/json" },
1664
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1624
1665
  body: JSON.stringify({ value })
1625
1666
  }).then(r => r.json());
1626
1667
 
@@ -1658,7 +1699,7 @@ async function toggleService(name) {
1658
1699
  try {
1659
1700
  const r = await fetch(BASE + "/toggle/" + name, {
1660
1701
  method: "POST",
1661
- headers: { "Content-Type": "application/json" },
1702
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1662
1703
  body: JSON.stringify({ enabled: newState })
1663
1704
  }).then(r => r.json());
1664
1705
 
@@ -1763,12 +1804,13 @@ async function changePassword() {
1763
1804
  try {
1764
1805
  const r = await fetch(BASE + "/change-pw", {
1765
1806
  method: "POST",
1766
- headers: { "Content-Type": "application/json" },
1807
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1767
1808
  body: JSON.stringify({ newPassword: newPw })
1768
1809
  }).then(r => r.json());
1769
1810
 
1770
1811
  if (r.locked) { showLockScreen(); return; }
1771
1812
  if (r.error) throw new Error(r.error);
1813
+ writeToken = r.write_token || null;
1772
1814
 
1773
1815
  msg.className = "chpw-msg ok"; msg.textContent = "✓ Password updated";
1774
1816
  document.getElementById("chpw-new").value = "";
@@ -1838,7 +1880,7 @@ async function addService() {
1838
1880
  if (project) payload.project = project;
1839
1881
  const r = await fetch(BASE + "/add-service", {
1840
1882
  method: "POST",
1841
- headers: { "Content-Type": "application/json" },
1883
+ headers: writeHeaders({ "Content-Type": "application/json" }),
1842
1884
  body: JSON.stringify(payload)
1843
1885
  }).then(r => r.json());
1844
1886
 
@@ -2058,7 +2100,7 @@ async function wizSubmitCfToken() {
2058
2100
 
2059
2101
  const r = await fetch(BASE + "/tunnel/setup/cf-token", {
2060
2102
  method: "POST",
2061
- headers: { "Content-Type": "application/json" },
2103
+ headers: writeHeaders({ "Content-Type": "application/json" }),
2062
2104
  body: JSON.stringify({ token }),
2063
2105
  }).then(r => r.json()).catch(() => null);
2064
2106
 
@@ -2496,6 +2538,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2496
2538
  const UNKNOWN_SERVICE_THRESHOLD = 2; // misses before we return the full service list
2497
2539
  let authHardLocked = false;
2498
2540
  let password = initPassword || null; // null = locked; set via POST /auth
2541
+ let writeSession = null; // null until explicit password auth grants write scope
2499
2542
  const machineHash = getMachineHash();
2500
2543
 
2501
2544
  // Rotation engine — starts after unlock
@@ -2522,6 +2565,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2522
2565
  whitelist,
2523
2566
  failCount,
2524
2567
  MAX_FAILS,
2568
+ writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
2525
2569
  };
2526
2570
  }
2527
2571
 
@@ -2916,6 +2960,22 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
2916
2960
  const studioDebugHandled = await studioDebugRuntime.handle(req, res, url, CORS);
2917
2961
  if (studioDebugHandled !== false) return;
2918
2962
 
2963
+ if (method === "GET" && reqPath === "/watchdog/services") {
2964
+ return ok(res, await getWatchdogStatuses());
2965
+ }
2966
+
2967
+ if (method === "GET" && reqPath === "/watchdog/events") {
2968
+ const limit = Number(url.searchParams.get("limit") || 100);
2969
+ return ok(res, { events: readWatchdogEvents(limit) });
2970
+ }
2971
+
2972
+ const restartMatch = reqPath.match(/^\/watchdog\/services\/([^/]+)\/restart$/);
2973
+ if (method === "POST" && restartMatch) {
2974
+ const result = restartWatchdogService(decodeURIComponent(restartMatch[1]));
2975
+ res.writeHead(result.ok ? 200 : 403, { "Content-Type": "application/json", ...CORS });
2976
+ return res.end(JSON.stringify(result));
2977
+ }
2978
+
2919
2979
  // ── Hosts that bypass OAuth (fresh domains for claude.ai compatibility) ──
2920
2980
  const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
2921
2981
  const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
@@ -3115,12 +3175,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3115
3175
  const MCP_PATHS = ["/mcp", "/gws", "/clauth", "/fs", "/chitchat", "/codevelop"];
3116
3176
  const isMcpPath = MCP_PATHS.includes(reqPath);
3117
3177
  function toolsForPath(p) {
3118
- if (p === "/gws") return MCP_TOOLS.filter(t => t.name.startsWith("gws_"));
3119
- if (p === "/clauth") return MCP_TOOLS.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
3120
- if (p === "/fs") return MCP_TOOLS.filter(t => t.name.startsWith("fs_"));
3121
- if (p === "/chitchat") return MCP_TOOLS.filter(t => t.name.startsWith("chitchat_"));
3122
- if (p === "/codevelop") return MCP_TOOLS.filter(t => t.name.startsWith("codevelop_"));
3123
- return MCP_TOOLS; // /mcp all tools
3178
+ const tools = filterMcpToolsForWriteMode(MCP_TOOLS);
3179
+ if (p === "/gws") return tools.filter(t => t.name.startsWith("gws_"));
3180
+ if (p === "/clauth") return tools.filter(t => t.name.startsWith("clauth_") || t.name === "monkey_dispatch" || t.name.startsWith("terminal_") || t.name.startsWith("channel_"));
3181
+ if (p === "/fs") return tools.filter(t => t.name.startsWith("fs_"));
3182
+ if (p === "/chitchat") return tools.filter(t => t.name.startsWith("chitchat_"));
3183
+ if (p === "/codevelop") return tools.filter(t => t.name.startsWith("codevelop_"));
3184
+ return tools; // /mcp — all tools
3124
3185
  }
3125
3186
  function serverNameForPath(p) {
3126
3187
  if (p === "/gws") return "gws";
@@ -3324,7 +3385,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
3324
3385
  if (rpcMethod === "tools/list") {
3325
3386
  sseSend(session.res, "message", {
3326
3387
  jsonrpc: "2.0", id,
3327
- result: { tools: MCP_TOOLS }
3388
+ result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
3328
3389
  });
3329
3390
  return;
3330
3391
  }
@@ -4121,6 +4182,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4121
4182
  return false;
4122
4183
  }
4123
4184
 
4185
+ function writeGuard(req, res) {
4186
+ if (lockedGuard(res)) return true;
4187
+ if (!validateWriteToken(req, writeSession)) {
4188
+ res.writeHead(403, { "Content-Type": "application/json", ...CORS });
4189
+ res.end(JSON.stringify({ error: "write token required", write_locked: true }));
4190
+ return true;
4191
+ }
4192
+ return false;
4193
+ }
4194
+
4124
4195
  // GET /meta — return valid key_types from DB check constraint
4125
4196
  if (method === "GET" && reqPath === "/meta") {
4126
4197
  if (lockedGuard(res)) return;
@@ -4311,6 +4382,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4311
4382
  const result = await api.test(pw, machineHash, token, timestamp);
4312
4383
  if (result.error) throw new Error(result.error);
4313
4384
  password = pw; // unlock — store in process memory only
4385
+ writeSession = makeWriteToken();
4314
4386
  authFailCount = 0;
4315
4387
  authHardLocked = false;
4316
4388
  const logLine = `[${new Date().toISOString()}] Vault unlocked\n`;
@@ -4365,7 +4437,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4365
4437
  tunnelStatus = "starting";
4366
4438
  startTunnel().catch(() => {});
4367
4439
  }
4368
- return ok(res, { ok: true, locked: false });
4440
+ return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
4369
4441
  } catch {
4370
4442
  authFailCount++;
4371
4443
  const authRemaining = MAX_AUTH_FAILS - authFailCount;
@@ -4386,6 +4458,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4386
4458
  // POST /lock — clear password from memory
4387
4459
  if (method === "POST" && reqPath === "/lock") {
4388
4460
  password = null;
4461
+ writeSession = null;
4389
4462
  stopTunnel();
4390
4463
  const logLine = `[${new Date().toISOString()}] Vault locked\n`;
4391
4464
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
@@ -4395,7 +4468,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4395
4468
  // POST /rename/:service — rename a service
4396
4469
  const renameMatch = reqPath.match(/^\/rename\/([a-zA-Z0-9_-]+)$/);
4397
4470
  if (method === "POST" && renameMatch) {
4398
- if (lockedGuard(res)) return;
4471
+ if (writeGuard(req, res)) return;
4399
4472
  const service = renameMatch[1].toLowerCase();
4400
4473
  let body;
4401
4474
  try { body = await readBody(req); } catch {
@@ -4420,7 +4493,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4420
4493
  // POST /delete/:service — remove a service entirely
4421
4494
  const deleteMatch = reqPath.match(/^\/delete\/([a-zA-Z0-9_-]+)$/);
4422
4495
  if (method === "POST" && deleteMatch) {
4423
- if (lockedGuard(res)) return;
4496
+ if (writeGuard(req, res)) return;
4424
4497
  const service = deleteMatch[1].toLowerCase();
4425
4498
  try {
4426
4499
  const { token, timestamp } = deriveToken(password, machineHash);
@@ -4435,7 +4508,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4435
4508
  // POST /toggle/:service — enable or disable a service
4436
4509
  const toggleMatch = reqPath.match(/^\/toggle\/([a-zA-Z0-9_-]+)$/);
4437
4510
  if (method === "POST" && toggleMatch) {
4438
- if (lockedGuard(res)) return;
4511
+ if (writeGuard(req, res)) return;
4439
4512
  const service = toggleMatch[1].toLowerCase();
4440
4513
 
4441
4514
  let body;
@@ -4519,7 +4592,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4519
4592
 
4520
4593
  // POST /rotate/:service — manually trigger rotation for a service
4521
4594
  if (method === "POST" && reqPath.startsWith("/rotate/")) {
4522
- if (lockedGuard(res)) return;
4595
+ if (writeGuard(req, res)) return;
4523
4596
  const service = reqPath.slice("/rotate/".length);
4524
4597
  if (!service) {
4525
4598
  res.writeHead(400, { "Content-Type": "application/json", ...CORS });
@@ -4531,7 +4604,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4531
4604
 
4532
4605
  // POST /set-expiry/:service — set expiry date for a service
4533
4606
  if (method === "POST" && reqPath.startsWith("/set-expiry/")) {
4534
- if (lockedGuard(res)) return;
4607
+ if (writeGuard(req, res)) return;
4535
4608
  const service = reqPath.slice("/set-expiry/".length);
4536
4609
  let body;
4537
4610
  try { body = await readBody(req); } catch {
@@ -4688,7 +4761,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4688
4761
 
4689
4762
  // POST /tunnel/setup/cf-token
4690
4763
  if (method === "POST" && reqPath === "/tunnel/setup/cf-token") {
4691
- if (lockedGuard(res)) return;
4764
+ if (writeGuard(req, res)) return;
4692
4765
  let body;
4693
4766
  try { body = await readBody(req); } catch { return strike(res, 400, "Invalid JSON"); }
4694
4767
  const { token: cfToken } = body;
@@ -4708,9 +4781,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4708
4781
  const accountId = ad?.result?.[0]?.id;
4709
4782
  const accountName = ad?.result?.[0]?.name;
4710
4783
 
4711
- // Save token to vault using api.write (same as /set/:service)
4712
- const { token: t, timestamp } = deriveToken(password, machineHash);
4713
- await api.write(password, machineHash, t, timestamp, "cloudflare", cfToken);
4784
+ // Save token to vault using the same guarded recovery path as /set/:service.
4785
+ await writeCredentialWithRecovery({ password, machineHash, service: "cloudflare", value: cfToken, logFile: LOG_FILE });
4714
4786
 
4715
4787
  // Save accountId to clauth_config
4716
4788
  const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
@@ -4991,7 +5063,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
4991
5063
 
4992
5064
  // POST /change-pw — change master password (must be unlocked)
4993
5065
  if (method === "POST" && reqPath === "/change-pw") {
4994
- if (lockedGuard(res)) return;
5066
+ if (writeGuard(req, res)) return;
4995
5067
 
4996
5068
  let body;
4997
5069
  try { body = await readBody(req); } catch {
@@ -5011,9 +5083,10 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5011
5083
  const result = await api.changePassword(password, machineHash, token, timestamp, newSeedHash);
5012
5084
  if (result.error) throw new Error(result.error);
5013
5085
  password = newPassword; // update in-memory password to new one
5086
+ writeSession = makeWriteToken();
5014
5087
  const logLine = `[${new Date().toISOString()}] Password changed\n`;
5015
5088
  try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
5016
- return ok(res, { ok: true });
5089
+ return ok(res, { ok: true, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
5017
5090
  } catch (err) {
5018
5091
  res.writeHead(502, { "Content-Type": "application/json", ...CORS });
5019
5092
  return res.end(JSON.stringify({ error: err.message }));
@@ -5023,7 +5096,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5023
5096
  // POST /set/:service — write a new key value into vault
5024
5097
  const setMatch = reqPath.match(/^\/set\/([a-zA-Z0-9_-]+)$/);
5025
5098
  if (method === "POST" && setMatch) {
5026
- if (lockedGuard(res)) return;
5099
+ if (writeGuard(req, res)) return;
5027
5100
  const service = setMatch[1].toLowerCase();
5028
5101
 
5029
5102
  if (whitelist && !whitelist.includes(service)) {
@@ -5042,10 +5115,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5042
5115
  }
5043
5116
 
5044
5117
  try {
5045
- const { token, timestamp } = deriveToken(password, machineHash);
5046
- const result = await api.write(password, machineHash, token, timestamp, service, value.trim());
5118
+ const { result, snapshot, normalized } = await writeCredentialWithRecovery({ password, machineHash, service, value, logFile: LOG_FILE });
5047
5119
  if (result.error) return strike(res, 502, result.error);
5048
- return ok(res, { ok: true, service });
5120
+ return ok(res, { ok: true, service, recovery_snapshot: snapshot?.ok ? true : false, normalized });
5049
5121
  } catch (err) {
5050
5122
  return strike(res, 502, err.message);
5051
5123
  }
@@ -5053,7 +5125,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5053
5125
 
5054
5126
  // POST /generate-token — generate a cryptographically random bearer token and store it
5055
5127
  if (method === "POST" && reqPath === "/generate-token") {
5056
- if (lockedGuard(res)) return;
5128
+ if (writeGuard(req, res)) return;
5057
5129
 
5058
5130
  let body;
5059
5131
  try { body = await readBody(req); } catch {
@@ -5075,10 +5147,9 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5075
5147
  try {
5076
5148
  const randomHex = crypto.randomBytes(32).toString("hex");
5077
5149
  const token = `${prefix}${randomHex}`;
5078
- const { token: authToken, timestamp } = deriveToken(password, machineHash);
5079
- const result = await api.write(password, machineHash, authToken, timestamp, service, token);
5150
+ const { result, snapshot } = await writeCredentialWithRecovery({ password, machineHash, service, value: token, logFile: LOG_FILE, normalize: false });
5080
5151
  if (result.error) return strike(res, 502, result.error);
5081
- return ok(res, { token, service, stored: true });
5152
+ return ok(res, { token, service, stored: true, recovery_snapshot: snapshot?.ok ? true : false });
5082
5153
  } catch (err) {
5083
5154
  return strike(res, 502, err.message);
5084
5155
  }
@@ -5086,7 +5157,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5086
5157
 
5087
5158
  // POST /add-service — register a new service in the vault
5088
5159
  if (method === "POST" && reqPath === "/add-service") {
5089
- if (lockedGuard(res)) return;
5160
+ if (writeGuard(req, res)) return;
5090
5161
 
5091
5162
  let body;
5092
5163
  try { body = await readBody(req); } catch {
@@ -5118,7 +5189,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5118
5189
 
5119
5190
  // POST /update-service — update service metadata (project, label, description)
5120
5191
  if (method === "POST" && reqPath === "/update-service") {
5121
- if (lockedGuard(res)) return;
5192
+ if (writeGuard(req, res)) return;
5122
5193
 
5123
5194
  let body;
5124
5195
  try { body = await readBody(req); } catch {
@@ -6316,7 +6387,7 @@ async function getFileserverMounts(vault) {
6316
6387
  async function resolveInMount(requestedPath, mountName, vault) {
6317
6388
  const { mounts, error } = await getFileserverMounts(vault);
6318
6389
  if (error) return { error };
6319
- if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwd\"}" };
6390
+ if (!mounts || mounts.length === 0) return { error: "No fileserver services configured. Add one with key_type='fileserver' and value: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (access flags: r=read w=write d=delete g=git)" };
6320
6391
  const mount = mountName ? mounts.find(m => m.name === mountName) : mounts[0];
6321
6392
  if (!mount) return { error: `Mount '${mountName}' not found. Available: ${mounts.map(m => m.name).join(", ")}` };
6322
6393
  if (!mount.path) return { error: `Fileserver '${mount.name}' has no path configured` };
@@ -6411,6 +6482,15 @@ function runGitRaw(cwd, args, opts = {}) {
6411
6482
  return res.stdout || Buffer.alloc(0);
6412
6483
  }
6413
6484
 
6485
+ // Read a single credential value from the vault inside an MCP handler.
6486
+ // (The git operations themselves live in ../lib/fs-git.js — pure + testable.)
6487
+ async function vaultRetrieveValue(vault, service) {
6488
+ if (!vault.password) return { error: "locked" };
6489
+ if (vault.whitelist && !vault.whitelist.includes(service.toLowerCase())) return { error: "not_in_whitelist" };
6490
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
6491
+ return api.retrieve(vault.password, vault.machineHash, token, timestamp, service);
6492
+ }
6493
+
6414
6494
  function normalizeRepoPath(p) {
6415
6495
  if (!p || typeof p !== "string") return null;
6416
6496
  const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
@@ -7144,8 +7224,63 @@ const MCP_TOOLS = [
7144
7224
  description: "List configured filesystem mounts (fileserver services from vault).",
7145
7225
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
7146
7226
  },
7227
+ {
7228
+ name: "fs_repo_status",
7229
+ description: "Get the current git state of the mounted repo in ONE call: branch, HEAD sha + subject, whether the tree is clean or dirty (with changed paths), staged paths, how far ahead/behind the remote you are, and whether a merge or rebase is in progress. Call this first so you KNOW what you are working with before committing — you never have to guess whether your edits or a previous push landed. Requires 'g' (git) access on the mount.",
7230
+ inputSchema: {
7231
+ type: "object",
7232
+ properties: {
7233
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7234
+ },
7235
+ additionalProperties: false,
7236
+ },
7237
+ },
7238
+ {
7239
+ name: "fs_use_branch",
7240
+ description: "Safely make sure you are on a branch before committing. Switches to an existing branch, or creates a new one from the current HEAD when create=true. Your uncommitted edits are carried forward; if switching would overwrite local changes it refuses and leaves the tree untouched, telling you which files block the switch. Refuses while a merge/rebase is in progress. Requires 'g' (git) access on the mount.",
7241
+ inputSchema: {
7242
+ type: "object",
7243
+ properties: {
7244
+ branch: { type: "string", description: "Branch name to switch to (or create)" },
7245
+ create: { type: "boolean", description: "Create a new branch from current HEAD instead of switching to an existing one (default false)" },
7246
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7247
+ },
7248
+ required: ["branch"],
7249
+ additionalProperties: false,
7250
+ },
7251
+ },
7252
+ {
7253
+ name: "fs_commit",
7254
+ description: "Save the files you edited durably: stages changes, commits, and PUSHES by default — usually a single call is all you need after editing. Handles the messy cases for you: nothing-to-commit returns cleanly (no error); a merge/rebase in progress or a detached HEAD is refused with a clear message; if the branch is behind the remote it auto-rebases and retries the push, and on conflict it aborts the rebase (restoring your tree) and keeps the commit local so nothing is lost. Refuses to push protected branches (main/master/production) — those are promoted by a human. Returns the commit sha, the exact files committed, whether the push succeeded, and ahead/behind counts, so you never need a follow-up status call. Push auth uses the vault's github token directly (never exposed). Requires 'g' (git) access on the mount.",
7255
+ inputSchema: {
7256
+ type: "object",
7257
+ properties: {
7258
+ message: { type: "string", description: "Commit message (required)" },
7259
+ paths: { type: "array", items: { type: "string" }, description: "Repo-relative paths to commit. Omit to commit ALL current changes in the repo." },
7260
+ push: { type: "boolean", description: "Push to the remote after committing (default true)" },
7261
+ remote: { type: "string", description: "Git remote name (default: origin)" },
7262
+ author_name: { type: "string", description: "Commit author name (default: clauth-fs)" },
7263
+ author_email: { type: "string", description: "Commit author email (default: fs@clauth.local)" },
7264
+ mount: { type: "string", description: "Mount name (default: first mount)" },
7265
+ },
7266
+ required: ["message"],
7267
+ additionalProperties: false,
7268
+ },
7269
+ },
7147
7270
  ];
7148
7271
 
7272
+ const MCP_WRITE_TOOL_NAMES = new Set([
7273
+ "clauth_enable",
7274
+ "clauth_disable",
7275
+ "clauth_set_project",
7276
+ "clauth_generate_token",
7277
+ ]);
7278
+
7279
+ function filterMcpToolsForWriteMode(tools) {
7280
+ if (process.env.CLAUTH_MCP_WRITE === "1") return tools;
7281
+ return tools.filter(tool => !MCP_WRITE_TOOL_NAMES.has(tool.name));
7282
+ }
7283
+
7149
7284
  function writeTempSecret(service, value) {
7150
7285
  const filePath = path.join(os.tmpdir(), `.clauth-${service}`);
7151
7286
  fs.writeFileSync(filePath, value, { mode: 0o600 });
@@ -7176,6 +7311,11 @@ function mcpError(text) {
7176
7311
  const GWS_EXEC_OPTS = { encoding: "utf8", timeout: 30000, windowsHide: true, shell: os.platform() === "win32" ? "bash" : undefined };
7177
7312
 
7178
7313
  async function handleMcpTool(vault, name, args) {
7314
+ const requireMcpWrite = () => {
7315
+ if (vault.writeEnabled) return null;
7316
+ return mcpError("MCP write tools are disabled by default. Launch clauth with CLAUTH_MCP_WRITE=1 for an explicit write-capable session.");
7317
+ };
7318
+
7179
7319
  switch (name) {
7180
7320
  case "clauth_ping": {
7181
7321
  return mcpResult(
@@ -7357,6 +7497,8 @@ async function handleMcpTool(vault, name, args) {
7357
7497
  }
7358
7498
 
7359
7499
  case "clauth_enable": {
7500
+ const writeError = requireMcpWrite();
7501
+ if (writeError) return writeError;
7360
7502
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7361
7503
  const service = (args.service || "").toLowerCase();
7362
7504
  if (!service) return mcpError("service is required");
@@ -7371,6 +7513,8 @@ async function handleMcpTool(vault, name, args) {
7371
7513
  }
7372
7514
 
7373
7515
  case "clauth_disable": {
7516
+ const writeError = requireMcpWrite();
7517
+ if (writeError) return writeError;
7374
7518
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7375
7519
  const service = (args.service || "").toLowerCase();
7376
7520
  if (!service) return mcpError("service is required");
@@ -7410,6 +7554,8 @@ async function handleMcpTool(vault, name, args) {
7410
7554
  }
7411
7555
 
7412
7556
  case "clauth_set_project": {
7557
+ const writeError = requireMcpWrite();
7558
+ if (writeError) return writeError;
7413
7559
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7414
7560
  const service = (args.service || "").toLowerCase();
7415
7561
  const project = args.project;
@@ -7461,6 +7607,8 @@ async function handleMcpTool(vault, name, args) {
7461
7607
  }
7462
7608
 
7463
7609
  case "clauth_generate_token": {
7610
+ const writeError = requireMcpWrite();
7611
+ if (writeError) return writeError;
7464
7612
  if (!vault.password) return mcpError("Vault is locked — call clauth_unlock first");
7465
7613
  const service = (args.service || "").trim().toLowerCase();
7466
7614
  const prefix = args.prefix || "";
@@ -7468,8 +7616,14 @@ async function handleMcpTool(vault, name, args) {
7468
7616
  try {
7469
7617
  const randomHex = crypto.randomBytes(32).toString("hex");
7470
7618
  const generatedToken = `${prefix}${randomHex}`;
7471
- const { token: authToken, timestamp } = deriveToken(vault.password, vault.machineHash);
7472
- const result = await api.write(vault.password, vault.machineHash, authToken, timestamp, service, generatedToken);
7619
+ const { result } = await writeCredentialWithRecovery({
7620
+ password: vault.password,
7621
+ machineHash: vault.machineHash,
7622
+ service,
7623
+ value: generatedToken,
7624
+ logFile: LOG_FILE,
7625
+ normalize: false,
7626
+ });
7473
7627
  if (result.error) return mcpError(result.error);
7474
7628
  return mcpResult(`Token generated and stored under "${service}": ${generatedToken}`);
7475
7629
  } catch (err) {
@@ -8058,10 +8212,67 @@ async function handleMcpTool(vault, name, args) {
8058
8212
  case "fs_mounts": {
8059
8213
  const { mounts, error } = await getFileserverMounts(vault);
8060
8214
  if (error) return mcpError(error);
8061
- if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwd\"}");
8215
+ if (!mounts || mounts.length === 0) return mcpResult("No fileserver mounts configured. Create one:\n1. Use clauth dashboard or clauth_enable to add a service with key_type='fileserver'\n2. Set the secret value to JSON: {\"path\": \"C:/Dev/regen-root\", \"access\": \"rwdg\"} (add 'g' to allow the git verbs: fs_commit, fs_use_branch, fs_repo_status)");
8062
8216
  return mcpResult(JSON.stringify(mounts, null, 2));
8063
8217
  }
8064
8218
 
8219
+ case "fs_repo_status": {
8220
+ const r = await resolveInMount(".", args.mount, vault);
8221
+ if (r.error) return mcpError(r.error);
8222
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8223
+ try {
8224
+ const { result, error } = await fsGit.repoStatus(r.resolved);
8225
+ if (error) return mcpError(error);
8226
+ return mcpResult(JSON.stringify(result, null, 2));
8227
+ } catch (err) {
8228
+ return mcpError(`Status failed: ${err.message}`);
8229
+ }
8230
+ }
8231
+
8232
+ case "fs_use_branch": {
8233
+ const r = await resolveInMount(".", args.mount, vault);
8234
+ if (r.error) return mcpError(r.error);
8235
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8236
+ try {
8237
+ const { result, error } = await fsGit.useBranch(r.resolved, args.branch, args.create === true);
8238
+ if (error) return mcpError(error);
8239
+ return mcpResult(JSON.stringify(result, null, 2));
8240
+ } catch (err) {
8241
+ return mcpError(`Branch switch failed: ${err.message}`);
8242
+ }
8243
+ }
8244
+
8245
+ case "fs_commit": {
8246
+ const r = await resolveInMount(".", args.mount, vault);
8247
+ if (r.error) return mcpError(r.error);
8248
+ if (!checkAccess(r.mount, "g")) return mcpError("Git access denied on this mount. Add 'g' to the mount's access string (e.g. 'rwdg') to enable the git verbs.");
8249
+ const push = args.push !== false;
8250
+ // Fetch the push token from the vault up front (commit happens first, so
8251
+ // a missing token still preserves the local commit).
8252
+ let token = null, tokenError = null;
8253
+ if (push) {
8254
+ const sec = await vaultRetrieveValue(vault, "github");
8255
+ if (sec.error || !sec.value) tokenError = `could not read the 'github' token (${sec.error || "empty"})`;
8256
+ else token = sec.value;
8257
+ }
8258
+ try {
8259
+ const { result, error } = await fsGit.commit(r.resolved, {
8260
+ message: args.message,
8261
+ paths: args.paths,
8262
+ push,
8263
+ remote: args.remote,
8264
+ token,
8265
+ tokenError,
8266
+ authorName: args.author_name,
8267
+ authorEmail: args.author_email,
8268
+ });
8269
+ if (error) return mcpError(error);
8270
+ return mcpResult(JSON.stringify(result, null, 2));
8271
+ } catch (err) {
8272
+ return mcpError(`Commit failed: ${err.message}`);
8273
+ }
8274
+ }
8275
+
8065
8276
  case "monkey_dispatch": {
8066
8277
  const { prompt, job_id } = args;
8067
8278
  if (!prompt) return mcpError("prompt required");
@@ -8251,6 +8462,7 @@ function createMcpServer(initPassword, whitelist) {
8251
8462
  whitelist,
8252
8463
  failCount: 0,
8253
8464
  MAX_FAILS: 10,
8465
+ writeEnabled: process.env.CLAUTH_MCP_WRITE === "1",
8254
8466
  };
8255
8467
 
8256
8468
  const rl = createInterface({ input: process.stdin, terminal: false });
@@ -8289,7 +8501,7 @@ function createMcpServer(initPassword, whitelist) {
8289
8501
  if (msg.method === "tools/list") {
8290
8502
  return send({
8291
8503
  jsonrpc: "2.0", id,
8292
- result: { tools: MCP_TOOLS }
8504
+ result: { tools: filterMcpToolsForWriteMode(MCP_TOOLS) }
8293
8505
  });
8294
8506
  }
8295
8507