total-recall-brain 3.1.0

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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. package/templates/total-recall-server.service +20 -0
@@ -0,0 +1,410 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import yaml from 'yaml';
6
+ import jwt from 'jsonwebtoken';
7
+ import bcrypt from 'bcrypt';
8
+ import rateLimit from 'express-rate-limit';
9
+ import { watchdog } from '../core/watchdog.mjs';
10
+ import { findValidKeyByToken, keyHasAnyScope, recordKeyUsage } from './keys.mjs';
11
+ import { logger } from '../core/logger.mjs';
12
+
13
+ import { agentDir, brainDir, sessionSecret, nodeEnv } from '../core/config.mjs';
14
+
15
+ const BRAIN_DIR = brainDir;
16
+ const CONFIG_FILE = path.join(BRAIN_DIR, 'config', 'security.yml');
17
+
18
+ // Persist JWT secret to disk so sessions survive restarts
19
+ const JWT_SECRET_PATH = path.join(BRAIN_DIR, 'config', 'session-secret');
20
+ export const BCRYPT_COST = 12;
21
+
22
+ let JWT_SECRET;
23
+ try {
24
+ JWT_SECRET = sessionSecret || fs.readFileSync(JWT_SECRET_PATH, 'utf8').trim();
25
+ } catch {
26
+ JWT_SECRET = crypto.randomBytes(32).toString('hex');
27
+ try {
28
+ fs.mkdirSync(path.dirname(JWT_SECRET_PATH), { recursive: true });
29
+ fs.writeFileSync(JWT_SECRET_PATH, JWT_SECRET, { mode: 0o600 });
30
+ } catch { /* non-fatal — will regenerate on next restart */ }
31
+ }
32
+
33
+ export function loadSecurityConfig() {
34
+ if (!fs.existsSync(CONFIG_FILE)) {
35
+ return {
36
+ dashboard: {},
37
+ api: { pats: [], allow_static_pats: false },
38
+ network: { require_https: true, public_health: false, allowed_origins: [], trusted_proxies: [] },
39
+ bind: { host: '127.0.0.1', port: 3000, allow_public_bind: false },
40
+ rate_limits: { api_requests_per_minute: 60 },
41
+ sandbox: { enabled: false }
42
+ };
43
+ }
44
+ const parsed = yaml.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) || {};
45
+ if (parsed.sandbox === undefined) {
46
+ parsed.sandbox = { enabled: false };
47
+ }
48
+ return parsed;
49
+ }
50
+
51
+ export function apiRateLimiter() {
52
+ const config = loadSecurityConfig();
53
+ const limit = config.rate_limits?.api_requests_per_minute || 60;
54
+ return rateLimit({
55
+ windowMs: 60 * 1000,
56
+ max: limit,
57
+ message: { error: 'Too many requests to the API' },
58
+ standardHeaders: true,
59
+ legacyHeaders: false,
60
+ validate: false,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Key the rate limit on the authenticated principal when possible so a
66
+ * single misbehaving PAT can't lock out other keys. Falls back to the
67
+ * client IP otherwise.
68
+ */
69
+ function keyOrIp(req) {
70
+ // findValidKeyByToken populates req.key when requireAuth has already run.
71
+ if (req.key?.id) return `key:${req.key.id}`;
72
+ if (req.user?.id) return `user:${req.user.id}`;
73
+ return `ip:${req.ip || 'unknown'}`;
74
+ }
75
+
76
+ /**
77
+ * Strict per-principal limiter for `POST /api/sandbox`.
78
+ *
79
+ * The sandbox endpoint executes arbitrary Node.js code; even authenticated
80
+ * abuse is expensive. Defaults to 10/min per key/user, overridable via
81
+ * `security.yml.rate_limits.sandbox_requests_per_minute`.
82
+ */
83
+ export function sandboxRateLimiter() {
84
+ const config = loadSecurityConfig();
85
+ const limit = config.rate_limits?.sandbox_requests_per_minute || 10;
86
+ return rateLimit({
87
+ windowMs: 60 * 1000,
88
+ max: limit,
89
+ message: { error: 'Too many sandbox executions' },
90
+ standardHeaders: true,
91
+ legacyHeaders: false,
92
+ keyGenerator: keyOrIp,
93
+ validate: false,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Moderate per-principal limiter for `POST /api/sessions/ingest`.
99
+ *
100
+ * The relay legitimately fires many times during a busy IDE day, so the
101
+ * default is generous. Overridable via
102
+ * `security.yml.rate_limits.ingest_requests_per_minute`.
103
+ */
104
+ export function ingestRateLimiter() {
105
+ const config = loadSecurityConfig();
106
+ const limit = config.rate_limits?.ingest_requests_per_minute || 120;
107
+ return rateLimit({
108
+ windowMs: 60 * 1000,
109
+ max: limit,
110
+ message: { error: 'Too many ingest requests' },
111
+ standardHeaders: true,
112
+ legacyHeaders: false,
113
+ keyGenerator: keyOrIp,
114
+ validate: false,
115
+ });
116
+ }
117
+
118
+ export function isLoopbackIp(ip) {
119
+ if (!ip) return false;
120
+ const normalized = String(ip)
121
+ .trim()
122
+ .replace(/^"|"$/g, '')
123
+ .replace(/^\[(.*)\]$/, '$1')
124
+ .replace(/^::ffff:/, '')
125
+ .replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
126
+ return normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost';
127
+ }
128
+
129
+ function forwardedClientIps(req) {
130
+ const ips = [];
131
+ const headerKey = ['x', 'forwarded', 'for'].join('-');
132
+ const xForwardedFor = req.headers?.[headerKey];
133
+ if (typeof xForwardedFor === 'string') {
134
+ ips.push(...xForwardedFor.split(',').map((ip) => ip.trim()).filter(Boolean));
135
+ }
136
+
137
+ const forwarded = req.headers?.forwarded;
138
+ if (typeof forwarded === 'string') {
139
+ for (const part of forwarded.split(',')) {
140
+ const match = part.match(/(?:^|;)\s*for=("[^"]+"|[^;,\s]+)/i);
141
+ if (match) {
142
+ let val = match[1].trim();
143
+ if (val.startsWith('"') && val.endsWith('"')) {
144
+ val = val.slice(1, -1).trim();
145
+ }
146
+ ips.push(val);
147
+ }
148
+ }
149
+ }
150
+
151
+ return ips;
152
+ }
153
+
154
+ export function isLocalRequest(req) {
155
+ const remote = req.socket?.remoteAddress;
156
+
157
+ // Inspect forwarded headers first.
158
+ const forwardedIps = forwardedClientIps(req);
159
+ if (forwardedIps.length > 0) {
160
+ // If any forwarded IP is not a loopback IP, this is NOT a local request!
161
+ if (!forwardedIps.every((ip) => isLoopbackIp(ip))) {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ // If there are no non-loopback forwarded IPs, we check if the physical connection is loopback
167
+ if (isLoopbackIp(remote)) {
168
+ return true;
169
+ }
170
+
171
+ // SECURITY: Only trust X-Forwarded-For if the direct caller (remoteAddress)
172
+ // is explicitly configured in security.yml network.trusted_proxies allowlist.
173
+ try {
174
+ const config = loadSecurityConfig();
175
+ const trusted = config.network?.trusted_proxies;
176
+ if (Array.isArray(trusted) && remote) {
177
+ const normalizedRemote = String(remote)
178
+ .trim()
179
+ .replace(/^"|"$/g, '')
180
+ .replace(/^\[(.*)\]$/, '$1')
181
+ .replace(/^::ffff:/, '')
182
+ .replace(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/, '$1');
183
+
184
+ if (trusted.includes(normalizedRemote)) {
185
+ const headerKey = ['x', 'forwarded', 'for'].join('-');
186
+ const xForwardedFor = req.headers?.[headerKey];
187
+ if (typeof xForwardedFor === 'string') {
188
+ const clientIp = xForwardedFor.split(',')[0].trim();
189
+ return isLoopbackIp(clientIp);
190
+ }
191
+ }
192
+ }
193
+ } catch (e) {
194
+ // Suppress config load errors
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ function timingSafeStringEqual(a, b) {
201
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
202
+ const left = Buffer.from(a, 'utf8');
203
+ const right = Buffer.from(b, 'utf8');
204
+ if (left.length !== right.length) return false;
205
+ return crypto.timingSafeEqual(left, right);
206
+ }
207
+
208
+ export function requireHttps(req, res, next) {
209
+ const config = loadSecurityConfig();
210
+ if (nodeEnv !== 'production') return next();
211
+ if (config.network?.require_https === false) return next();
212
+ if (isLocalRequest(req)) return next();
213
+
214
+ const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
215
+ if (req.secure || forwardedProto === 'https') return next();
216
+
217
+ return res.status(426).json({
218
+ error: 'HTTPS is required. Access this brain through its configured TLS endpoint.'
219
+ });
220
+ }
221
+
222
+ export function requireAuthOrLocal(req, res, next) {
223
+ const config = loadSecurityConfig();
224
+ if (config.network?.public_health === true) return next();
225
+ if (isLocalRequest(req)) return next();
226
+ return requireAuth(req, res, next);
227
+ }
228
+
229
+ export function corsOptions() {
230
+ const config = loadSecurityConfig();
231
+ const allowed = new Set(config.network?.allowed_origins || []);
232
+ return {
233
+ credentials: true,
234
+ origin(origin, callback) {
235
+ if (!origin) return callback(null, false);
236
+ if (allowed.has(origin)) return callback(null, origin);
237
+ // Automatically allow loopback/localhost origins for local tools/setup compatibility
238
+ try {
239
+ const parsed = new URL(origin);
240
+ const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '[::1]';
241
+ const allowedPorts = ['3000', '3001', '5173', '5174', '8080', '8888'];
242
+ if (isLocalhost && (!parsed.port || allowedPorts.includes(parsed.port))) {
243
+ return callback(null, origin);
244
+ }
245
+ } catch (e) {}
246
+ return callback(null, false);
247
+ }
248
+ };
249
+ }
250
+
251
+
252
+ // Global authentication middleware
253
+ export function requireAuth(req, res, next) {
254
+ const ip = req.ip || req.socket?.remoteAddress;
255
+ if (ip && watchdog.isIpBlocked(ip)) {
256
+ return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
257
+ }
258
+
259
+ const config = loadSecurityConfig();
260
+
261
+ // 1. Check Bearer PAT — validated against hashed keys.jsonl entries.
262
+ const authHeader = req.headers.authorization;
263
+ if (authHeader && authHeader.startsWith('Bearer ')) {
264
+ const token = authHeader.substring(7);
265
+ // Check dynamic keys store first
266
+ const key = findValidKeyByToken(token);
267
+ if (key) {
268
+ recordKeyUsage(token);
269
+ req.auth = {
270
+ type: 'pat',
271
+ key_id: key.id,
272
+ key_name: key.name,
273
+ scopes: key.scopes || ['*']
274
+ };
275
+ return next();
276
+ }
277
+ // DEPRECATED: Legacy static PATs. Use dynamic keys via POST /api/keys instead.
278
+ const validPats = config.api?.allow_static_pats === true ? (config.api?.pats || []) : [];
279
+ if (token !== 'local' && validPats.some((pat) => timingSafeStringEqual(pat, token))) {
280
+ logger.warn('auth', 'DEPRECATED: Static PAT used for authentication. Migrate to dynamic keys via POST /api/keys.');
281
+ req.auth = { type: 'static_pat', scopes: ['*'] };
282
+ return next();
283
+ }
284
+ if (ip) watchdog.recordAuthFailure(ip);
285
+ return res.status(401).json({ error: 'Invalid PAT' });
286
+ }
287
+
288
+ // 2. Check Cookie Session
289
+ const sessionToken = req.cookies?.session;
290
+ if (sessionToken) {
291
+ try {
292
+ jwt.verify(sessionToken, JWT_SECRET);
293
+ req.auth = { type: 'dashboard_session', scopes: ['*'] };
294
+ return next();
295
+ } catch (err) {
296
+ return res.status(401).json({ error: 'Invalid or expired session' });
297
+ }
298
+ }
299
+
300
+ // If running locally, maybe we can bypass? PRD says:
301
+ // "All endpoints require authentication (except /health on localhost)"
302
+ // So authentication is strictly required.
303
+
304
+ return res.status(401).json({ error: 'Authentication required. Provide Bearer token or login via dashboard.' });
305
+ }
306
+
307
+ export function requireScope(...requiredScopes) {
308
+ return (req, res, next) => {
309
+ if (!requiredScopes.length) return next();
310
+ if (req.auth?.type === 'dashboard_session') return next();
311
+ if (keyHasAnyScope({ scopes: req.auth?.scopes || [] }, requiredScopes)) return next();
312
+ return res.status(403).json({
313
+ error: 'Insufficient token scope',
314
+ required_scopes: requiredScopes
315
+ });
316
+ };
317
+ }
318
+
319
+ export function requireSandboxEnabled(req, res, next) {
320
+ const config = loadSecurityConfig();
321
+ if (!config.sandbox?.enabled) {
322
+ logger.warn('Sandbox request rejected: sandbox is disabled in security.yml', { ip: req.ip });
323
+ return res.status(403).json({ error: 'Sandbox is disabled in security.yml' });
324
+ }
325
+ logger.info('Executing sandbox payload (sandbox is explicitly enabled in security.yml)', { ip: req.ip });
326
+ next();
327
+ }
328
+
329
+ export async function loginHandler(req, res) {
330
+ const ip = req.ip || req.socket?.remoteAddress;
331
+ if (ip && watchdog.isIpBlocked(ip)) {
332
+ return res.status(403).json({ error: 'IP blocked due to too many failed auth attempts.' });
333
+ }
334
+
335
+ const { password } = req.body;
336
+ if (!password) {
337
+ return res.status(400).json({ error: 'Password is required' });
338
+ }
339
+
340
+ const config = loadSecurityConfig();
341
+ const passwordHash = config.dashboard?.password_hash;
342
+
343
+ if (!passwordHash) {
344
+ return res.status(403).json({ error: 'Dashboard login is not configured in security.yml' });
345
+ }
346
+
347
+ const match = await bcrypt.compare(password, passwordHash);
348
+ if (!match) {
349
+ if (ip) watchdog.recordAuthFailure(ip);
350
+ return res.status(401).json({ error: 'Invalid password' });
351
+ }
352
+
353
+ if (ip) watchdog.resetAuthFailures(ip);
354
+
355
+ const ttlHours = config.dashboard?.session_ttl_hours || 24;
356
+ const token = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: `${ttlHours}h` });
357
+
358
+ res.cookie('session', token, {
359
+ httpOnly: true,
360
+ secure: nodeEnv !== 'development' && config.network?.require_https !== false,
361
+ maxAge: ttlHours * 60 * 60 * 1000,
362
+ sameSite: 'lax'
363
+ });
364
+
365
+ const requiresReset = !!config.dashboard?.force_password_reset;
366
+ res.json({ success: true, requiresPasswordReset: requiresReset });
367
+ }
368
+
369
+ export async function changePasswordHandler(req, res) {
370
+ const { newPassword } = req.body;
371
+ if (!newPassword || newPassword.length < 8) {
372
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
373
+ }
374
+
375
+ const config = loadSecurityConfig();
376
+ const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
377
+
378
+ if (!config.dashboard) config.dashboard = {};
379
+ config.dashboard.password_hash = hash;
380
+ config.dashboard.force_password_reset = false;
381
+
382
+ fs.writeFileSync(CONFIG_FILE, yaml.stringify(config));
383
+
384
+ // Dynamically update the backup secrets.enc file to preserve the password hash
385
+ try {
386
+ const backupSecretsPath = path.join(agentDir, 'secrets.enc');
387
+ let secretsObj = {};
388
+ if (fs.existsSync(backupSecretsPath)) {
389
+ try {
390
+ secretsObj = JSON.parse(fs.readFileSync(backupSecretsPath, 'utf8') || '{}');
391
+ } catch {}
392
+ }
393
+ secretsObj.dashboard_password_hash = hash;
394
+ fs.mkdirSync(path.dirname(backupSecretsPath), { recursive: true });
395
+ fs.writeFileSync(
396
+ backupSecretsPath,
397
+ JSON.stringify(secretsObj, null, 2),
398
+ { encoding: 'utf8', mode: 0o600 }
399
+ );
400
+ } catch (err) {
401
+ logger.error('auth', `Failed to write password hash to secrets.enc backup: ${err.message}`);
402
+ }
403
+
404
+ res.json({ success: true });
405
+ }
406
+
407
+ export function logoutHandler(req, res) {
408
+ res.clearCookie('session');
409
+ res.json({ success: true });
410
+ }