total-recall-brain 3.30.2 → 3.31.2

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.
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-DGMNZi_1.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-FOJG4jQh.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -16,7 +16,7 @@
16
16
  "react": "^19.2.6",
17
17
  "react-dom": "^19.2.6",
18
18
  "react-force-graph-3d": "^1.29.1",
19
- "react-router-dom": "^7.15.0",
19
+ "react-router-dom": "^7.18.4",
20
20
  "recharts": "^3.9.2",
21
21
  "three": "^0.185.1",
22
22
  "three-spritetext": "^1.10.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.30.2",
3
+ "version": "3.31.2",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -16,7 +16,6 @@ repo_scoped: true
16
16
  > **Only agent skill in this package.** Implementation helpers live in `modules/` (not skills).
17
17
  > Openwiki ships with the brain at `openwiki/`.
18
18
 
19
-
20
19
  Welcome to the master control skill for the **Total Recall Autonomous AI OS**. > [!NOTE]
21
20
  > **Total Recall Architecture:**
22
21
  > The Total Recall memory kernel runs as a standalone REST service. Agents interact with the memory vault, perform semantic searches, and compile rules directly using lightweight, filesystem-native REST API calls via standard shell `curl` commands.
@@ -71,7 +70,9 @@ Use the **MESH / CONTROL SERVER** section. Node details are entity fields on
71
70
  SSSS `mesh_node` documents — never hardcoded hostnames.
72
71
 
73
72
  ### 5d. When running tests, typechecks, or builds
74
- See **TESTS**. Heavy runs go to the Mac Mini or the droplet, never the laptop.
73
+ See **TESTS**. The full suite and the quality gates run only on the Mac Mini —
74
+ never on a laptop or the cloud droplet. A single spec file may run on any mesh node. Builds and installs are fine on any machine; build large apps
75
+ on the Mac Mini.
75
76
 
76
77
  ### 6. When troubleshooting connections, port blocks, or sync errors
77
78
  * **Trigger**: If the REST server is unreachable, ports are blocked, or the upstream sync tool encounters errors.
@@ -553,12 +554,6 @@ that dead-ends back at a manual dashboard step.
553
554
 
554
555
  ---
555
556
 
556
- <!-- BEGIN INJECTED MEMORY: do not edit by hand; rebuilt by total-recall surface -->
557
- <!-- @route: tfidf, generated_at: 2026-05-21T03:34:14.837Z -->
558
-
559
- <!-- END INJECTED MEMORY -->
560
-
561
-
562
557
  ---
563
558
 
564
559
  ## 🕸️ MESH / CONTROL SERVER (`mesh` subcommand)
@@ -602,9 +597,11 @@ npx vitest run <path> # one spec file
602
597
 
603
598
  Current baseline (3.30.1): **349 spec files / 2024 tests, ~160 s** on the Mac Mini.
604
599
 
605
- > **Never run the full suite, a full typecheck, or a production build on the
606
- > laptop.** These go to the Mac Mini or the droplet — local runs cause slowdowns
607
- > and OOM kills. Sync and run remotely:
600
+ > **The full suite and the quality gates run only on the Mac Mini** — never on a
601
+ > laptop or the cloud droplet; they cause slowdowns and OOM kills. A single spec
602
+ > file may run on any mesh node. Builds
603
+ > and installs are fine anywhere; build large apps on the Mac Mini. Sync and run
604
+ > remotely:
608
605
  >
609
606
  > ```bash
610
607
  > REMOTE=build-host; REMOTE_DIR=code/total-recall # your own host and path
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import yaml from 'yaml';
4
4
  import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
5
+ import { writeFileSecure } from '../core/secure-file.mjs';
5
6
 
6
7
  // Pre-defined mapping of known settings
7
8
  const KEY_MAP = {
@@ -224,7 +225,7 @@ export default async function configCommand(args) {
224
225
  if (!fs.existsSync(configDir)) {
225
226
  fs.mkdirSync(configDir, { recursive: true });
226
227
  }
227
- fs.writeFileSync(targetPath, yaml.stringify(targetObj), { encoding: 'utf8', mode: 0o600 });
228
+ writeFileSecure(targetPath, yaml.stringify(targetObj), { encoding: 'utf8', mode: 0o600 });
228
229
 
229
230
  console.log(`\n ✅ Success! Config key "${key}" ${layerLabel} set to: ${JSON.stringify(parsedValue)}`);
230
231
  console.log(` 📍 Saved in: ${targetPath}\n`);
@@ -19,6 +19,7 @@ import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import bcrypt from 'bcrypt';
21
21
  import yaml from 'yaml';
22
+ import { writeFileSecure } from '../core/secure-file.mjs';
22
23
  import { BCRYPT_COST } from '../server/auth.mjs';
23
24
 
24
25
 
@@ -175,7 +176,7 @@ export function startDeployUI(port = 3001) {
175
176
  if (opts.cloudRegion) current['cfg-cloud-region'] = opts.cloudRegion;
176
177
  if (opts.installGlobal !== undefined) current['installGlobal'] = opts.installGlobal;
177
178
  if (opts.projectPaths) current['projectPaths'] = opts.projectPaths;
178
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
179
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
179
180
 
180
181
  } catch (e) {
181
182
  console.error('Failed to auto-persist install options on disk:', e);
@@ -572,7 +573,7 @@ export function startDeployUI(port = 3001) {
572
573
  }
573
574
 
574
575
  const updated = { ...current, ...data };
575
- fs.writeFileSync(configFile, JSON.stringify(updated, null, 2), { encoding: 'utf8', mode: 0o600 });
576
+ writeFileSecure(configFile, JSON.stringify(updated, null, 2), { encoding: 'utf8', mode: 0o600 });
576
577
  syncInstallOptionsFromDisk();
577
578
 
578
579
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1114,7 +1115,7 @@ export function startDeployUI(port = 3001) {
1114
1115
  }
1115
1116
  current['cfg-github-token'] = token;
1116
1117
  current['backupRepo'] = backupUrl;
1117
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1118
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1118
1119
 
1119
1120
  log('✅ Encryption keys and tokens written.');
1120
1121
 
@@ -1735,7 +1736,7 @@ console.log("REMOTE_PASS_OK");
1735
1736
  if (_installOptions.dashboardPassword) {
1736
1737
  current['cfg-dashboard-password'] = _installOptions.dashboardPassword;
1737
1738
  }
1738
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1739
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1739
1740
  } catch (e) {
1740
1741
  console.error('Failed to auto-persist remote install options on disk:', e);
1741
1742
  }
@@ -43,6 +43,7 @@ import yaml from 'yaml';
43
43
  import bcrypt from 'bcrypt';
44
44
  import crypto from 'node:crypto';
45
45
  import { BCRYPT_COST } from '../server/auth.mjs';
46
+ import { writeFileSecure } from '../core/secure-file.mjs';
46
47
 
47
48
 
48
49
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -407,7 +408,7 @@ function hardenSecurityConfig(dryRun, dashboardPassword = null) {
407
408
  log(' 🔑 Dashboard credentials left unconfigured. You will set your admin password on first dashboard access.');
408
409
  }
409
410
 
410
- fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
411
+ writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
411
412
  logOk('Security config hardened: HTTPS required, Express bound to localhost, no public health, no legacy local PAT');
412
413
  }
413
414
 
@@ -1095,7 +1096,7 @@ export default async function deploy(args) {
1095
1096
  if (wizardOpts.dashboardPassword) {
1096
1097
  current['cfg-dashboard-password'] = wizardOpts.dashboardPassword;
1097
1098
  }
1098
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1099
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1099
1100
  } catch (e) {
1100
1101
  console.error('Failed to auto-persist cloud install options on disk:', e);
1101
1102
  }
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { resolveBrainDir, parseLayerFlag, getBothBrains } from './agent-dir.mjs';
5
+ import { writeNodeValidatedAsync } from '../core/validated-write.mjs';
6
+ import { isSafeVaultName } from '../core/vault.mjs';
7
+
8
+ const PRIORITIES = ['absolute', 'high', 'normal', 'low'];
9
+ const MODALITIES = ['must', 'must_not', 'should', 'should_not', 'descriptive', 'preference'];
10
+ const STATUSES = ['active', 'archived', 'deprecated'];
11
+
12
+ function printHelp() {
13
+ console.log(`
14
+ total-recall edit — Change an existing memory node in place (same slug)
15
+
16
+ Usage: total-recall edit <slug> ["<new content>"] [options]
17
+
18
+ Options:
19
+ --title <text> Replace the title
20
+ --importance, -i <1-5> Replace the importance
21
+ --priority, -p <level> absolute | high | normal | low
22
+ --modality, -m <type> must | must_not | should | should_not | descriptive | preference
23
+ --tags, -t <list> Replace the tags (comma-separated)
24
+ --status <status> active | archived | deprecated
25
+ --global Edit the node in the global brain
26
+ --project Edit the node in the project brain
27
+ --no-compile Skip auto-recompilation after the edit
28
+ --help, -h Show this help
29
+
30
+ Pass "-" as the content to read the new body from stdin. Omit the content to
31
+ keep the body and change only the options given.
32
+
33
+ If neither --global nor --project is specified, the node is looked up in the
34
+ project brain first (if one exists), then in the global brain.
35
+
36
+ Examples:
37
+ npx total-recall edit anti-patterns-7a7fb6a5 "Never run tests on the MacBook."
38
+ npx total-recall edit my-rule --priority absolute --modality must --global
39
+ cat new-body.md | npx total-recall edit my-fact -
40
+ `);
41
+ }
42
+
43
+ /** Path of `<slug>.md` inside any category directory of `vaultDir`, or null. */
44
+ export function findNodeFile(slug, vaultDir) {
45
+ if (!isSafeVaultName(slug) || !fs.existsSync(vaultDir)) return null;
46
+ for (const entry of fs.readdirSync(vaultDir, { withFileTypes: true })) {
47
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
48
+ const filePath = path.join(vaultDir, entry.name, `${slug}.md`);
49
+ if (fs.existsSync(filePath)) return filePath;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Parse edit arguments into { slug, body, changes, noCompile }.
56
+ * Throws with a user-facing message on invalid input.
57
+ */
58
+ export function parseEditArgs(args) {
59
+ const result = { slug: null, body: undefined, changes: {}, noCompile: false };
60
+ const positional = [];
61
+ for (let i = 0; i < args.length; i++) {
62
+ const arg = args[i];
63
+ const next = () => {
64
+ const value = args[++i];
65
+ if (value === undefined) throw new Error(`${arg} requires a value.`);
66
+ return value;
67
+ };
68
+ if (arg === '--no-compile') result.noCompile = true;
69
+ else if (arg === '--title') result.changes.title = next();
70
+ else if (arg === '--importance' || arg === '-i') {
71
+ const value = Number(next());
72
+ if (!Number.isInteger(value) || value < 1 || value > 5) {
73
+ throw new Error('--importance must be an integer from 1 to 5.');
74
+ }
75
+ result.changes.importance = value;
76
+ } else if (arg === '--priority' || arg === '-p') {
77
+ const value = next().toLowerCase();
78
+ if (!PRIORITIES.includes(value)) throw new Error(`--priority must be one of: ${PRIORITIES.join(', ')}.`);
79
+ result.changes.priority = value;
80
+ } else if (arg === '--modality' || arg === '-m') {
81
+ const value = next().toLowerCase();
82
+ if (!MODALITIES.includes(value)) throw new Error(`--modality must be one of: ${MODALITIES.join(', ')}.`);
83
+ result.changes.modality = value;
84
+ } else if (arg === '--tags' || arg === '-t') {
85
+ result.changes.tags = next().split(',').map((t) => t.trim()).filter(Boolean);
86
+ } else if (arg === '--status') {
87
+ const value = next().toLowerCase();
88
+ if (!STATUSES.includes(value)) throw new Error(`--status must be one of: ${STATUSES.join(', ')}.`);
89
+ result.changes.status = value;
90
+ } else if (/^--?[a-z]/i.test(arg)) {
91
+ throw new Error(`Unknown option: ${arg}`);
92
+ } else positional.push(arg);
93
+ }
94
+ if (positional.length > 2) throw new Error('Quote the new content as a single argument.');
95
+ [result.slug, result.body] = positional;
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Apply an edit to a parsed node. Returns the updated node and the list of
101
+ * changed field names. `immutable` follows `priority`, as in `remember`.
102
+ */
103
+ export function applyEdit(node, { body, changes }, now = new Date().toISOString()) {
104
+ const updated = { ...node };
105
+ const changed = [];
106
+ if (body !== undefined && body.trim() !== (node.body || '').trim()) {
107
+ updated.body = body.trim();
108
+ changed.push('body');
109
+ }
110
+ for (const [key, value] of Object.entries(changes)) {
111
+ if (JSON.stringify(node[key]) === JSON.stringify(value)) continue;
112
+ updated[key] = value;
113
+ changed.push(key);
114
+ }
115
+ if (changed.includes('title') && node.description === node.title) {
116
+ updated.description = updated.title;
117
+ }
118
+ if (changed.includes('priority')) {
119
+ if (updated.priority === 'absolute') updated.immutable = true;
120
+ else delete updated.immutable;
121
+ }
122
+ if (changed.length) updated.updated = now;
123
+ return { node: updated, changed };
124
+ }
125
+
126
+ function locate(slug, explicitLayer) {
127
+ const layers = explicitLayer === 'auto' ? ['project', 'global'] : [explicitLayer];
128
+ const brains = getBothBrains();
129
+ for (const layer of layers) {
130
+ if (layer === 'project' && !brains.project) continue;
131
+ const vaultDir = path.join(resolveBrainDir(layer), 'memory-vault');
132
+ const filePath = findNodeFile(slug, vaultDir);
133
+ if (filePath) return { layer, vaultDir, filePath };
134
+ }
135
+ return null;
136
+ }
137
+
138
+ async function readStdin() {
139
+ const chunks = [];
140
+ for await (const chunk of process.stdin) chunks.push(chunk);
141
+ return Buffer.concat(chunks).toString('utf8');
142
+ }
143
+
144
+ export default async function edit(args) {
145
+ const { layer: explicitLayer, remainingArgs } = parseLayerFlag(args);
146
+ if (!remainingArgs.length || remainingArgs.includes('--help') || remainingArgs.includes('-h')) {
147
+ printHelp();
148
+ return;
149
+ }
150
+
151
+ let parsed;
152
+ try {
153
+ parsed = parseEditArgs(remainingArgs);
154
+ } catch (err) {
155
+ console.error(` ❌ ${err.message}`);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ if (parsed.body === '-') parsed.body = await readStdin();
160
+ if (parsed.body !== undefined && !parsed.body.trim()) {
161
+ console.error(' ❌ The new content is empty. Use `total-recall forget` to delete a node.');
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+
166
+ const found = locate(parsed.slug, explicitLayer);
167
+ if (!found) {
168
+ const where = explicitLayer === 'auto' ? 'project or global' : explicitLayer;
169
+ console.error(` ❌ Node "${parsed.slug}" not found in the ${where} vault.`);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+
174
+ const { data, content } = matter(fs.readFileSync(found.filePath, 'utf8'));
175
+ const { node, changed } = applyEdit({ ...data, body: content.trim() }, parsed);
176
+ const layerLabel = `[${found.layer}]`;
177
+ const relPath = path.relative(path.dirname(found.vaultDir), found.filePath);
178
+
179
+ if (!changed.length) {
180
+ console.log(` ℹ️ Nothing to change in ${layerLabel} ${relPath}.`);
181
+ return;
182
+ }
183
+
184
+ const result = await writeNodeValidatedAsync(node, found.vaultDir);
185
+ if (!result.success) {
186
+ const errors = result.validation?.errors || [result.error || 'unknown validation failure'];
187
+ console.error(` ❌ Validation failed: ${errors.join('; ')}`);
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ console.log(` ✅ Updated ${layerLabel} ${relPath} (${changed.join(', ')})`);
192
+
193
+ if (parsed.noCompile) return;
194
+ console.log(' ⏳ Recompiling active memory surfaces and indexes in the background...');
195
+ try {
196
+ const { spawn } = await import('node:child_process');
197
+ const child = spawn(process.argv[0], [process.argv[1], 'compile', `--${found.layer}`], {
198
+ detached: true,
199
+ stdio: 'ignore',
200
+ });
201
+ child.unref();
202
+ console.log(' ✅ Background compilation started.');
203
+ } catch (err) {
204
+ console.warn(` ⚠️ Node updated, but background recompilation spawn failed: ${err.message}`);
205
+ }
206
+ }
package/src/cli/help.mjs CHANGED
@@ -146,7 +146,7 @@ function printGeneralHelp() {
146
146
  npx total-recall help architecture
147
147
 
148
148
  \x1b[1mCore Memory Operations:\x1b[0m
149
- \x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
149
+ \x1b[33mremember\x1b[0m, \x1b[33mrecall\x1b[0m, \x1b[33mforget\x1b[0m, \x1b[33medit\x1b[0m, \x1b[33mbrain\x1b[0m, \x1b[33mmap\x1b[0m
150
150
 
151
151
  \x1b[1mCompilation & System Rules:\x1b[0m
152
152
  \x1b[33mcompile\x1b[0m, \x1b[33mrebuild\x1b[0m, \x1b[33mconnect\x1b[0m, \x1b[33mskill\x1b[0m, \x1b[33mimport-rules\x1b[0m
package/src/cli/init.mjs CHANGED
@@ -31,6 +31,7 @@ import os from 'node:os';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { exec, spawn, spawnSync } from 'node:child_process';
33
33
  import { projectSkillsForScope, detectActiveSkillTargets } from './skill-projection.mjs';
34
+ import { writeFileSecure } from '../core/secure-file.mjs';
34
35
 
35
36
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
37
  const ROOT = path.join(__dirname, '..', '..');
@@ -408,10 +409,10 @@ export default async function init(args) {
408
409
  }
409
410
 
410
411
  if (!config.dashboard.password_hash) {
411
- fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: "utf8", mode: 0o600 });
412
+ writeFileSecure(securityPath, yaml.stringify(config), { encoding: "utf8", mode: 0o600 });
412
413
  passwordMessage = "\n 🔑 Dashboard Access: Unconfigured\n (You will be prompted to set your password upon first browser access)";
413
414
  } else {
414
- fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
415
+ writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
415
416
  passwordMessage = '\n 🔑 Dashboard credentials preserved (custom password hash already configured)';
416
417
  }
417
418
  } catch (err) {
@@ -586,7 +587,7 @@ export default async function init(args) {
586
587
  current['cfg-health-url'] = 'http://localhost:3000/health';
587
588
  }
588
589
 
589
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
590
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
590
591
  logOk('UI deploy configuration persisted to wizard-config.json!');
591
592
  }
592
593
 
@@ -670,7 +671,7 @@ export default async function init(args) {
670
671
  fs.mkdirSync(cfgDir, { recursive: true });
671
672
  const brainCfg = { url: opts.brain };
672
673
  if (opts.token) brainCfg.token = opts.token;
673
- fs.writeFileSync(path.join(cfgDir, 'brain.json'), JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
674
+ writeFileSecure(path.join(cfgDir, 'brain.json'), JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
674
675
  logOk(`Registered brain at ${opts.brain}. Run \`npx total-recall sync\` to pull instructions.`);
675
676
  } else {
676
677
  // Zero-config bootstrap: auto-generate localhost brain.json and valid Developer PAT out-of-the-box
@@ -696,7 +697,7 @@ export default async function init(args) {
696
697
  const keyData = issueKey('Default Local Developer Key', { scopes: ['*'] });
697
698
  brainCfg.url = brainCfg.url || 'http://localhost:3000';
698
699
  brainCfg.token = brainCfg.token || keyData.token;
699
- fs.writeFileSync(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
700
+ writeFileSecure(brainJsonPath, JSON.stringify(brainCfg, null, 2), { mode: 0o600 });
700
701
  logOk(`Bootstrap configuration successfully generated and pre-authorized at ${brainCfg.url}`);
701
702
  } catch (err) {
702
703
  logWarn(`Could not auto-generate bootstrap developer key: ${err.message}`);
@@ -786,7 +787,7 @@ export default async function init(args) {
786
787
  current['cfg-dash-url'] = `${tunnelUrl}/`;
787
788
  current['cfg-health-url'] = `${tunnelUrl}/health`;
788
789
 
789
- fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
790
+ writeFileSecure(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
790
791
  logOk('Registered Quick Tunnel URL in wizard-config.json!');
791
792
  dashboardUrl = `${tunnelUrl}/`;
792
793
  } else {
@@ -5,6 +5,7 @@ import bcrypt from 'bcrypt';
5
5
  import yaml from 'yaml';
6
6
  import { BCRYPT_COST } from '../server/auth.mjs';
7
7
  import { brainDir } from '../core/config.mjs';
8
+ import { writeFileSecure } from '../core/secure-file.mjs';
8
9
 
9
10
  /**
10
11
  * Read a password without echoing it, and without it ever becoming an argv
@@ -128,7 +129,7 @@ export default async function resetPassword(args) {
128
129
  const isDefault = password === 'totalrecall';
129
130
  config.dashboard.force_password_reset = isDefault;
130
131
 
131
- fs.writeFileSync(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
132
+ writeFileSecure(securityPath, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
132
133
 
133
134
  // Never echo the password back. The operator just typed it; repeating it to
134
135
  // stdout puts it into scrollback, into any log capturing this command, and
@@ -30,6 +30,7 @@ import os from 'node:os';
30
30
  import path from 'node:path';
31
31
  import { spawnSync, execFileSync } from 'node:child_process';
32
32
  import backup from './backup.mjs';
33
+ import { writeFileSecure } from '../core/secure-file.mjs';
33
34
 
34
35
  // ╔══════════════════════════════════════════════════════════════════════════╗
35
36
  // ║ FORBIDDEN FLAGS — These patterns must NEVER be accepted as arguments ║
@@ -531,7 +532,7 @@ export default async function uninstall(args = []) {
531
532
 
532
533
  if (Object.keys(secretsObj).length > 0) {
533
534
  fs.mkdirSync(agentDir, { recursive: true });
534
- fs.writeFileSync(
535
+ writeFileSecure(
535
536
  path.join(agentDir, 'secrets.enc'),
536
537
  JSON.stringify(secretsObj, null, 2),
537
538
  { encoding: 'utf8', mode: 0o600 }
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
  import YAML from 'yaml';
22
+ import { writeFileSecure } from './secure-file.mjs';
22
23
 
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  const ROOT = path.resolve(__dirname, '../..');
@@ -162,7 +163,7 @@ export function writeBrainIdentity(brainDir, { name, role = 'project', tags = []
162
163
  updated_at: new Date().toISOString(),
163
164
  created_at: current.created_at || new Date().toISOString(),
164
165
  };
165
- fs.writeFileSync(brainJsonPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
166
+ writeFileSecure(brainJsonPath, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
166
167
  return next;
167
168
  }
168
169
 
@@ -13,6 +13,7 @@ import os from 'os';
13
13
  import YAML from 'yaml';
14
14
  import { encryptSecrets, decryptSecrets, encryptSecretsSync, decryptSecretsSync } from './crypto.mjs';
15
15
  import { DEFAULT_KEYCHAIN_SERVICE, readKeychainPassword } from './secrets-keychain.mjs';
16
+ import { writeFileSecure, appendFileSecure } from './secure-file.mjs';
16
17
 
17
18
  const META_KEY = '__tr_secrets_meta';
18
19
 
@@ -170,7 +171,7 @@ export function saveSecretsSync(brainDir, obj) {
170
171
  const password = secretsPassword();
171
172
  if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
172
173
  const buf = encryptSecretsSync(obj, password);
173
- fs.writeFileSync(filePath, buf, { mode: 0o600 });
174
+ writeFileSecure(filePath, buf, { mode: 0o600 });
174
175
  }
175
176
 
176
177
  /**
@@ -221,7 +222,7 @@ export async function saveSecrets(brainDir, secrets) {
221
222
 
222
223
  if (!password) throw new Error('TR_SECRETS_PASSWORD or TR_MASTER_PASSWORD is required to write secrets');
223
224
  const buf = await encryptSecrets(secrets, password);
224
- fs.writeFileSync(filePath, buf, { mode: 0o600 });
225
+ writeFileSecure(filePath, buf, { mode: 0o600 });
225
226
  }
226
227
 
227
228
  /**
@@ -314,7 +315,7 @@ function appendAudit(brainDir, event) {
314
315
  ...event,
315
316
  // never include values
316
317
  });
317
- fs.appendFileSync(auditPath, line + '\n', { mode: 0o600 });
318
+ appendFileSecure(auditPath, line + '\n', { mode: 0o600 });
318
319
  }
319
320
 
320
321
  /**
@@ -983,7 +984,7 @@ export function recordUsage(brainDir, event) {
983
984
  source: event.source || 'cli',
984
985
  key_ref: event.key_ref || null, // secret *name* only, never value
985
986
  };
986
- fs.appendFileSync(usagePath, JSON.stringify(row) + '\n', { mode: 0o600 });
987
+ appendFileSecure(usagePath, JSON.stringify(row) + '\n', { mode: 0o600 });
987
988
  return row;
988
989
  }
989
990
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Secure file writes for credential-bearing files.
3
+ *
4
+ * `fs.writeFileSync`/`fs.appendFileSync` only apply their `mode` option when the
5
+ * file is CREATED. Rewriting a file that already exists silently preserves the
6
+ * old permissions, so a security.yml or secrets.enc that was ever created under
7
+ * a loose umask (or restored from a backup, or copied by a sync) stays
8
+ * world-readable forever no matter how many times it is rewritten with
9
+ * `{ mode: 0o600 }`.
10
+ *
11
+ * Observed live: config/security.yml at mode 644 while holding the dashboard
12
+ * bcrypt password_hash.
13
+ *
14
+ * Every writer of a file containing hashes, tokens, or key material must go
15
+ * through here so the mode is re-asserted on every write, not just the first.
16
+ */
17
+
18
+ import fs from 'fs';
19
+
20
+ export const SECRET_FILE_MODE = 0o600;
21
+
22
+ /**
23
+ * Re-assert permissions on an existing file.
24
+ *
25
+ * @param {string} filePath
26
+ * @param {number} [mode]
27
+ * @returns {boolean} true when the mode was applied
28
+ */
29
+ export function chmodSecure(filePath, mode = SECRET_FILE_MODE) {
30
+ try {
31
+ fs.chmodSync(filePath, mode);
32
+ return true;
33
+ } catch {
34
+ // Best effort: chmod can fail on non-POSIX filesystems.
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function requireSecureMode(filePath, mode) {
40
+ if (!chmodSecure(filePath, mode)) {
41
+ throw new Error(`Could not secure file permissions: ${filePath}`);
42
+ }
43
+ }
44
+
45
+ /**
46
+ * writeFileSync that actually enforces `mode` on rewrite.
47
+ *
48
+ * @param {string} filePath
49
+ * @param {string|Buffer} data
50
+ * @param {object} [options] same as fs.writeFileSync; `mode` defaults to 0o600
51
+ */
52
+ export function writeFileSecure(filePath, data, options = {}) {
53
+ const { mode = SECRET_FILE_MODE, ...rest } = options;
54
+ if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
55
+ fs.writeFileSync(filePath, data, { ...rest, mode });
56
+ requireSecureMode(filePath, mode);
57
+ }
58
+
59
+ /**
60
+ * appendFileSync that actually enforces `mode` on an existing file.
61
+ *
62
+ * @param {string} filePath
63
+ * @param {string|Buffer} data
64
+ * @param {object} [options] same as fs.appendFileSync; `mode` defaults to 0o600
65
+ */
66
+ export function appendFileSecure(filePath, data, options = {}) {
67
+ const { mode = SECRET_FILE_MODE, ...rest } = options;
68
+ if (fs.existsSync(filePath)) requireSecureMode(filePath, SECRET_FILE_MODE);
69
+ fs.appendFileSync(filePath, data, { ...rest, mode });
70
+ requireSecureMode(filePath, mode);
71
+ }
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import crypto from 'node:crypto';
12
+ import { writeFileSecure } from './secure-file.mjs';
12
13
  import {
13
14
  generateRegistrationOptions,
14
15
  verifyRegistrationResponse,
@@ -49,7 +50,7 @@ function loadStore(brainDir) {
49
50
  function saveStore(brainDir, store) {
50
51
  const filePath = resolveWebAuthnPath(brainDir);
51
52
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
52
- fs.writeFileSync(filePath, JSON.stringify(store, null, 2) + '\n', {
53
+ writeFileSecure(filePath, JSON.stringify(store, null, 2) + '\n', {
53
54
  encoding: 'utf8',
54
55
  mode: 0o600,
55
56
  });
@@ -11,6 +11,7 @@ import { findValidKeyByToken, keyHasAnyScope, recordKeyUsage } from './keys.mjs'
11
11
  import { logger } from '../core/logger.mjs';
12
12
 
13
13
  import { agentDir, brainDir, sessionSecret, nodeEnv } from '../core/config.mjs';
14
+ import { writeFileSecure } from '../core/secure-file.mjs';
14
15
 
15
16
  const BRAIN_DIR = brainDir;
16
17
  const CONFIG_FILE = path.join(BRAIN_DIR, 'config', 'security.yml');
@@ -26,7 +27,7 @@ try {
26
27
  JWT_SECRET = crypto.randomBytes(32).toString('hex');
27
28
  try {
28
29
  fs.mkdirSync(path.dirname(JWT_SECRET_PATH), { recursive: true });
29
- fs.writeFileSync(JWT_SECRET_PATH, JWT_SECRET, { mode: 0o600 });
30
+ writeFileSecure(JWT_SECRET_PATH, JWT_SECRET, { mode: 0o600 });
30
31
  } catch { /* non-fatal — will regenerate on next restart */ }
31
32
  }
32
33
 
@@ -450,7 +451,7 @@ export async function changePasswordHandler(req, res) {
450
451
  config.dashboard.password_hash = hash;
451
452
  config.dashboard.force_password_reset = false;
452
453
 
453
- fs.writeFileSync(CONFIG_FILE, yaml.stringify(config));
454
+ writeFileSecure(CONFIG_FILE, yaml.stringify(config), { encoding: 'utf8', mode: 0o600 });
454
455
 
455
456
  // Dynamically update the backup secrets.enc file to preserve the password hash
456
457
  try {