alvin-bot 4.12.0 → 4.12.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.
package/dist/index.js CHANGED
@@ -20,6 +20,57 @@ if (hasLegacyData()) {
20
20
  }
21
21
  // 3. Seed defaults for any files that don't exist yet (fresh install)
22
22
  seedDefaults();
23
+ // 3a. v4.12.2 — Audit + repair permissions on sensitive files. On multi-user
24
+ // systems, files written pre-v4.12.2 may have 0o644 / 0o666 mode — i.e.
25
+ // readable by other users on the same machine. This routine chmod-repairs
26
+ // them to 0o600 (owner read/write only) at every startup. Idempotent for
27
+ // already-secure files; silent no-op for missing files.
28
+ import { auditSensitiveFiles } from "./services/file-permissions.js";
29
+ import { ENV_FILE as SEC_ENV, SESSIONS_STATE_FILE, MEMORY_FILE, CRON_FILE as SEC_CRON } from "./paths.js";
30
+ import { readdirSync } from "fs";
31
+ import { resolve as pathResolve } from "path";
32
+ import { MEMORY_DIR as SEC_MEM_DIR, DATA_DIR as SEC_DATA_DIR } from "./paths.js";
33
+ {
34
+ const sensitivePaths = [SEC_ENV, SESSIONS_STATE_FILE, MEMORY_FILE, SEC_CRON];
35
+ // Also audit every daily-log markdown file — they contain full conversation history
36
+ try {
37
+ if (readdirSync.length !== undefined) {
38
+ for (const entry of readdirSync(SEC_MEM_DIR)) {
39
+ if (entry.endsWith(".md") && !entry.startsWith(".")) {
40
+ sensitivePaths.push(pathResolve(SEC_MEM_DIR, entry));
41
+ }
42
+ }
43
+ }
44
+ }
45
+ catch {
46
+ // memory dir missing — fine
47
+ }
48
+ // Also include async-agents state, delivery queue, and sudo credentials
49
+ const optionalPaths = [
50
+ pathResolve(SEC_DATA_DIR, "state", "async-agents.json"),
51
+ pathResolve(SEC_DATA_DIR, "delivery-queue.json"),
52
+ pathResolve(SEC_DATA_DIR, "data", ".sudo-enc"),
53
+ pathResolve(SEC_DATA_DIR, "data", ".sudo-key"),
54
+ pathResolve(SEC_DATA_DIR, "data", "access.json"),
55
+ pathResolve(SEC_DATA_DIR, "data", "approved-users.json"),
56
+ ];
57
+ sensitivePaths.push(...optionalPaths);
58
+ const auditResults = auditSensitiveFiles(sensitivePaths);
59
+ const repaired = auditResults.filter(r => r.status === "repaired");
60
+ if (repaired.length > 0) {
61
+ console.log(`🔒 file-permissions: repaired ${repaired.length} sensitive file(s) to 0o600`);
62
+ for (const r of repaired) {
63
+ console.log(` ${r.path} (was 0o${r.previousMode})`);
64
+ }
65
+ }
66
+ const errors = auditResults.filter(r => r.status === "error");
67
+ if (errors.length > 0) {
68
+ console.warn(`⚠️ file-permissions: ${errors.length} file(s) could not be repaired:`);
69
+ for (const r of errors) {
70
+ console.warn(` ${r.path}: ${r.error}`);
71
+ }
72
+ }
73
+ }
23
74
  // 4. Crash-loop brake check — if we've crashed N times in a short window,
24
75
  // refuse to start, write an alert file, and unload our LaunchAgent so
25
76
  // launchd stops retrying. Runs BEFORE any expensive init so a broken
@@ -35,9 +86,30 @@ if (!hasTelegram) {
35
86
  console.warn("⚠️ BOT_TOKEN not set — Telegram disabled. WebUI + Cron still active.");
36
87
  console.warn(" Run 'alvin-bot setup' or set BOT_TOKEN in ~/.alvin-bot/.env");
37
88
  }
38
- if (config.allowedUsers.length === 0 && hasTelegram) {
39
- console.warn("⚠️ ALLOWED_USERS not set nobody can message the Telegram bot yet.");
40
- console.warn(" Send /start to @userinfobot on Telegram to find your ID.");
89
+ // v4.12.2 ALLOWED_USERS startup gate. Refuses to start when Telegram is
90
+ // configured but no user allowlist is set, because that would leave the bot
91
+ // open to any Telegram user with full shell/filesystem access via prompt
92
+ // injection. See src/services/allowed-users-gate.ts for the pure decision
93
+ // function + tests.
94
+ {
95
+ const { checkAllowedUsersGate } = await import("./services/allowed-users-gate.js");
96
+ const gate = checkAllowedUsersGate({
97
+ hasTelegram,
98
+ allowedUsersCount: config.allowedUsers.length,
99
+ authMode: config.authMode,
100
+ insecureAcknowledged: process.env.ALVIN_INSECURE_ACKNOWLEDGED === "1",
101
+ });
102
+ if (!gate.allowed) {
103
+ console.error("");
104
+ console.error("❌ CRITICAL: Alvin Bot refusing to start.");
105
+ console.error("");
106
+ console.error(" " + gate.reason);
107
+ console.error("");
108
+ process.exit(1);
109
+ }
110
+ if (gate.warning) {
111
+ console.warn("⚠️ " + gate.warning);
112
+ }
41
113
  }
42
114
  // Check if the chosen provider has a corresponding API key.
43
115
  // Keys here MUST match the registry keys from src/providers/registry.ts
@@ -114,7 +114,10 @@ export class ClaudeSDKProvider {
114
114
  allowDangerouslySkipPermissions: true,
115
115
  env: cleanEnv,
116
116
  settingSources: ["user", "project"],
117
- allowedTools: [
117
+ // v4.12.2 — options.allowedTools can override the default full set.
118
+ // Used by sub-agents with toolset="readonly"/"research" to restrict
119
+ // what Claude can do. Default = full access.
120
+ allowedTools: options.allowedTools ?? [
118
121
  "Read", "Write", "Edit", "Bash", "Glob", "Grep",
119
122
  "WebSearch", "WebFetch", "Task",
120
123
  ],
@@ -161,6 +164,24 @@ export class ClaudeSDKProvider {
161
164
  }
162
165
  if ("name" in block) {
163
166
  localToolUseCount++;
167
+ // v4.12.1 — Extract run_in_background from the raw input
168
+ // object BEFORE the 500-char JSON truncation below. This is
169
+ // load-bearing: for long prompts the serialized input can
170
+ // exceed 500 chars, and naive post-truncation parsing would
171
+ // lose the flag and misclassify sync tasks as async (→ false
172
+ // 10-min abort on legitimate long-running sub-agents).
173
+ // See src/handlers/stuck-timer.ts and message.ts for the
174
+ // consumer side.
175
+ let runInBackground;
176
+ if ("input" in block &&
177
+ block.input &&
178
+ typeof block.input === "object") {
179
+ const input = block.input;
180
+ if (input.run_in_background === true)
181
+ runInBackground = true;
182
+ else if (input.run_in_background === false)
183
+ runInBackground = false;
184
+ }
164
185
  // Serialise the tool input (parameters) so the message
165
186
  // handler can surface detail for specific tools — most
166
187
  // importantly the "Task" tool where `input.description`
@@ -176,10 +197,17 @@ export class ClaudeSDKProvider {
176
197
  // unserializable — skip
177
198
  }
178
199
  }
200
+ // Tool-use blocks in the Anthropic API always have an `id`
201
+ // at runtime, but the SDK's .d.ts shape doesn't guarantee it
202
+ // — defensive cast. Used by the task-aware stuck timer to
203
+ // correlate tool_use → tool_result for sync tracking.
204
+ const toolUseId = block.id;
179
205
  yield {
180
206
  type: "tool_use",
181
207
  toolName: block.name,
182
208
  toolInput: toolInputStr,
209
+ toolUseId,
210
+ runInBackground,
183
211
  sessionId: capturedSessionId,
184
212
  };
185
213
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * ALLOWED_USERS Startup Gate (v4.12.2)
3
+ *
4
+ * Pure decision function that runs at startup to decide whether Alvin should
5
+ * refuse to start because its Telegram bot is configured but has no user
6
+ * allowlist.
7
+ *
8
+ * Before v4.12.2, an empty ALLOWED_USERS with AUTH_MODE=allowlist would only
9
+ * emit a console.warn and the bot would start anyway. On production this
10
+ * left a "configured but unguarded" surface — any Telegram user who sends
11
+ * a DM would reach the bot and could exploit shell/filesystem access via
12
+ * prompt injection.
13
+ *
14
+ * The gate has two explicit escape hatches, both intentional:
15
+ * 1. AUTH_MODE=open — user explicitly wants a public bot (not recommended)
16
+ * 2. ALVIN_INSECURE_ACKNOWLEDGED=1 — explicit operator opt-out used for
17
+ * test environments and scripted installs where the operator
18
+ * acknowledges they know what they're doing.
19
+ *
20
+ * Pure: takes config values as args, returns a decision. The actual
21
+ * process.exit(1) lives in src/index.ts as a thin wrapper.
22
+ */
23
+ export function checkAllowedUsersGate(input) {
24
+ // WebUI-only deployments don't have a BOT_TOKEN → nothing to gate
25
+ if (!input.hasTelegram) {
26
+ return { allowed: true };
27
+ }
28
+ // Telegram is enabled AND allowlist is populated → normal path
29
+ if (input.allowedUsersCount > 0) {
30
+ return { allowed: true };
31
+ }
32
+ // Telegram enabled but allowlist empty — check escape hatches
33
+ if (input.authMode === "open") {
34
+ return {
35
+ allowed: true,
36
+ warning: "AUTH_MODE=open explicitly set. Any Telegram user can message the bot. " +
37
+ "This is NOT recommended for machines with sensitive files or shell access.",
38
+ };
39
+ }
40
+ if (input.insecureAcknowledged) {
41
+ return {
42
+ allowed: true,
43
+ warning: "ALVIN_INSECURE_ACKNOWLEDGED=1 set. Bot starts with empty ALLOWED_USERS. " +
44
+ "The operator has explicitly opted out of the safety gate.",
45
+ };
46
+ }
47
+ // No escape hatch — refuse to start
48
+ return {
49
+ allowed: false,
50
+ reason: "ALLOWED_USERS is empty but BOT_TOKEN is set. " +
51
+ "Alvin Bot has full shell/filesystem access on this machine, so starting with " +
52
+ "an empty allowlist would leave the bot open to anyone who sends it a Telegram message. " +
53
+ "Fix: set ALLOWED_USERS=<your telegram user id> in ~/.alvin-bot/.env (get your ID from @userinfobot). " +
54
+ "Explicit opt-out: AUTH_MODE=open OR ALVIN_INSECURE_ACKNOWLEDGED=1.",
55
+ };
56
+ }
@@ -124,6 +124,23 @@ async function executeJob(job) {
124
124
  }
125
125
  case "shell": {
126
126
  const cmd = job.payload.command || "echo 'no command'";
127
+ // v4.12.2 — Cron shell jobs now go through exec-guard. Before
128
+ // v4.12.2 cron bypassed the allowlist, which was inconsistent
129
+ // with the rest of the bot's shell execution policy. With
130
+ // EXEC_SECURITY=allowlist (default) this rejects jobs with
131
+ // shell metacharacters or non-allowlisted binaries. Operators
132
+ // who legitimately need complex shell pipelines in cron set
133
+ // EXEC_SECURITY=full explicitly.
134
+ const { checkExecAllowed } = await import("./exec-guard.js");
135
+ const guard = checkExecAllowed(cmd);
136
+ if (!guard.allowed) {
137
+ const msg = `Cron shell job blocked by exec-guard: ${guard.reason}`;
138
+ console.warn(`[cron] ${job.name}: ${msg}`);
139
+ if (notifyCallback) {
140
+ await notifyCallback(job.target, `🛑 ${job.name}\n${msg}\n\nSet EXEC_SECURITY=full if this is intentional.`);
141
+ }
142
+ return { output: msg };
143
+ }
127
144
  // Per-job timeout, default = no timeout (execSync treats timeout=0
128
145
  // or "undefined" as infinite). Users opt in via /cron add … --timeout N.
129
146
  const shellOpts = {
@@ -30,12 +30,37 @@ function extractBinary(command) {
30
30
  // Strip path: /usr/bin/curl -> curl
31
31
  return first.split("/").pop() || first;
32
32
  }
33
+ /**
34
+ * v4.12.2 — Reject shell metacharacters in allowlist mode.
35
+ *
36
+ * The pre-v4.12.2 allowlist check only inspected the first word of the
37
+ * command. That was trivially bypassable via:
38
+ * - ";" chaining: "echo safe; rm -rf /"
39
+ * - "&&" / "||" chains: "echo hi && cat /etc/passwd"
40
+ * - pipe: "cat /etc/passwd | head"
41
+ * - substitution: "echo $(whoami)" or "`whoami`"
42
+ * - redirect: "echo hi > /etc/passwd"
43
+ * - backgrounding: "... &"
44
+ *
45
+ * Strategy: in allowlist mode, any command containing any of these
46
+ * metachars is rejected outright. Users who need shell pipelines opt in
47
+ * explicitly via EXEC_SECURITY=full.
48
+ */
49
+ const SHELL_METACHAR_PATTERN = /[;&|`$(){}<>]/;
33
50
  export function checkExecAllowed(command) {
34
51
  if (config.execSecurity === "full")
35
52
  return { allowed: true };
36
53
  if (config.execSecurity === "deny")
37
54
  return { allowed: false, reason: "Shell execution is disabled" };
38
- // allowlist mode
55
+ // allowlist mode — v4.12.2 metachar guard
56
+ if (SHELL_METACHAR_PATTERN.test(command)) {
57
+ return {
58
+ allowed: false,
59
+ reason: `Command contains shell metacharacters (pipes, redirects, substitution, chaining). ` +
60
+ `Allowlist mode only permits simple binary invocations. ` +
61
+ `Set EXEC_SECURITY=full if you need shell pipelines.`,
62
+ };
63
+ }
39
64
  const binary = extractBinary(command);
40
65
  if (SAFE_BINS.includes(binary))
41
66
  return { allowed: true };
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import fs from "fs";
12
12
  import { FALLBACK_FILE, ENV_FILE, DATA_DIR } from "../paths.js";
13
+ import { writeSecure } from "./file-permissions.js";
13
14
  // ── Public API ──────────────────────────────────────────────────────────────
14
15
  /**
15
16
  * Get the current fallback order.
@@ -143,7 +144,9 @@ function syncToEnv(primary, fallbacks) {
143
144
  else {
144
145
  env += `\nFALLBACK_PROVIDERS=${fallbackStr}`;
145
146
  }
146
- fs.writeFileSync(ENV_FILE, env);
147
+ // v4.12.2 — writeSecure enforces 0o600 on .env so other users on the
148
+ // machine can't read tokens/API keys.
149
+ writeSecure(ENV_FILE, env);
147
150
  }
148
151
  catch (err) {
149
152
  console.error("Failed to sync fallback order to .env:", err);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * File Permissions Hardening (v4.12.2)
3
+ *
4
+ * On multi-user dev servers, Alvin's sensitive files (.env, sessions.json,
5
+ * memory files, cron-jobs.json) were previously written with the default
6
+ * umask — typically 0o644 on Linux/macOS, meaning any other user on the
7
+ * same machine could read API keys, conversation history, cron job
8
+ * definitions, etc.
9
+ *
10
+ * This module provides:
11
+ * - writeSecure(path, content) — atomic write with mode 0o600
12
+ * - ensureSecureMode(path) — chmod-repair an existing file if it's too permissive
13
+ * - auditSensitiveFiles(paths[]) — batch-audit a list of files and repair
14
+ *
15
+ * The handler strategy:
16
+ * - NEW writes: use writeSecure() or pass `{ mode: 0o600 }` to writeFileSync
17
+ * - STARTUP: call auditSensitiveFiles() once with the list of known-sensitive
18
+ * files to chmod-repair anything that was written pre-v4.12.2
19
+ *
20
+ * Pure file-system operations — no grammy, no session, testable in isolation.
21
+ */
22
+ import fs from "fs";
23
+ /** Strict mode for all sensitive files: owner read/write only. */
24
+ export const SECURE_MODE = 0o600;
25
+ /**
26
+ * Atomically write a file with mode 0o600.
27
+ *
28
+ * Uses fs.writeFileSync's built-in `mode` option for initial creation, then
29
+ * an explicit fs.chmodSync to handle the case where the file already exists
30
+ * (in which case the mode arg to writeFileSync is ignored).
31
+ */
32
+ export function writeSecure(path, content) {
33
+ fs.writeFileSync(path, content, { mode: SECURE_MODE });
34
+ // writeFileSync's mode is only applied on initial create. If the file
35
+ // already existed with a looser mode, we need to explicitly chmod it.
36
+ try {
37
+ fs.chmodSync(path, SECURE_MODE);
38
+ }
39
+ catch {
40
+ // Best effort — some filesystems (e.g. FAT) don't support chmod
41
+ }
42
+ }
43
+ /**
44
+ * Ensure a file is at most as permissive as SECURE_MODE (0o600). If it's
45
+ * already 0o600 or stricter (e.g. 0o400), leave it alone. If it's more
46
+ * permissive (e.g. 0o644, 0o666), repair it to 0o600.
47
+ *
48
+ * Returns a report of what happened — used by auditSensitiveFiles().
49
+ */
50
+ export function ensureSecureMode(path) {
51
+ let stat;
52
+ try {
53
+ stat = fs.statSync(path);
54
+ }
55
+ catch (err) {
56
+ const e = err;
57
+ if (e.code === "ENOENT") {
58
+ return { path, status: "missing" };
59
+ }
60
+ return { path, status: "error", error: e.message };
61
+ }
62
+ const currentMode = stat.mode & 0o777;
63
+ // If the file is already at SECURE_MODE or stricter (fewer bits), leave it.
64
+ // We use bitwise AND: if (currentMode & ~SECURE_MODE) === 0 then all set bits
65
+ // are within SECURE_MODE's bits — i.e. the file is not MORE permissive.
66
+ if ((currentMode & ~SECURE_MODE) === 0) {
67
+ return { path, status: "already-secure" };
68
+ }
69
+ // File is more permissive than 0o600 — repair.
70
+ try {
71
+ fs.chmodSync(path, SECURE_MODE);
72
+ return {
73
+ path,
74
+ status: "repaired",
75
+ previousMode: currentMode.toString(8),
76
+ };
77
+ }
78
+ catch (err) {
79
+ return {
80
+ path,
81
+ status: "error",
82
+ error: err instanceof Error ? err.message : String(err),
83
+ };
84
+ }
85
+ }
86
+ /**
87
+ * Audit + repair a list of sensitive file paths. Returns a report per file.
88
+ * Called once at bot startup with the list of known-sensitive files so that
89
+ * any file written pre-v4.12.2 (with default 0o644/0o666 umask) gets repaired.
90
+ */
91
+ export function auditSensitiveFiles(paths) {
92
+ return paths.map(p => ensureSecureMode(p));
93
+ }
@@ -61,43 +61,68 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
61
61
  * See test/system-prompt-background-hint.test.ts and
62
62
  * docs/superpowers/plans/2026-04-13-async-subagents.md
63
63
  */
64
- const BACKGROUND_SUBAGENT_HINT = `## Background Sub-Agents (IMPORTANT)
64
+ const BACKGROUND_SUBAGENT_HINT = `## ⚠️ CRITICAL: Background Sub-Agents (Telegram blocking rule)
65
65
 
66
- For long-running work, ALWAYS set \`run_in_background: true\` on your Agent
67
- tool call. This makes the tool return IMMEDIATELY with an \`agentId\` and
68
- \`outputFile\` path instead of blocking for the full duration. Your main
69
- turn ends quickly, the user's session unlocks, and they can keep chatting
70
- with me while the sub-agent works in parallel.
66
+ **The rule**: For any Agent/Task tool call that will take more than
67
+ ~30 seconds, you MUST set \`run_in_background: true\`. Otherwise the
68
+ Telegram user is COMPLETELY BLOCKED from messaging me until the task
69
+ finishes terrible UX for anything that ends up taking minutes or
70
+ hours. This is the single most important rule for using the Agent tool.
71
71
 
72
- After launching a background agent, you MUST:
73
- 1. Tell the user in one short sentence what you kicked off ("Starting SEO
74
- audit for gethomes.io in the background I'll send the report when
75
- it's done").
76
- 2. End your turn promptly. Do NOT wait around. Do NOT keep working on
77
- that task. Wrap up within 1-2 short messages.
78
- 3. When the agent finishes, its final report will arrive as a SEPARATE
79
- message in this chat — the bot handles that delivery automatically.
80
- You don't need to poll the outputFile proactively.
72
+ **Why it matters**: During a synchronous Agent tool call the parent
73
+ session has no way to know the sub-agent is still working. It appears
74
+ hung. After a long silence the session can be aborted, destroying the
75
+ work. Using \`run_in_background: true\` solves this: the tool returns
76
+ an \`agentId\` + \`outputFile\` path IMMEDIATELY, your turn ends in
77
+ seconds, the user can keep chatting with me, and the bot automatically
78
+ delivers the sub-agent's final result as a separate message when ready.
81
79
 
82
- If the user asks "is it done yet?" before the bot delivers the result,
83
- you MAY read the agent's \`outputFile\` (from the tool result) using the
84
- Read tool to check progress.
80
+ **Decision tree** (apply every time you consider the Agent/Task tool):
81
+
82
+ Does the task involve ANY of the following?
83
+ • Visiting more than 2 URLs
84
+ • Reading/analyzing more than 5 files
85
+ • A full-repo scan, audit, or refactor proposal
86
+ • A multi-section report or structured document
87
+ • Tasks you'd describe as "thorough", "comprehensive", or "deep"
88
+ • Shell commands that may take >30s (builds, installs, tests)
89
+ • Crawling, scraping, or fetching multiple resources
90
+ • Research across multiple sources or domains
91
+
92
+ YES → \`run_in_background: true\` (no exceptions)
93
+ NO → foreground is fine (single quick sub-query under 30s)
85
94
 
86
- **DO use \`run_in_background: true\` for:**
87
- - Audits (SEO, security, code quality, performance)
88
- - Research tasks that visit more than 3 web pages
89
- - Multi-file codebase analyses, full-repo scans
95
+ **Examples where you MUST use \`run_in_background: true\`:**
96
+ - ANY audit (SEO, security, code quality, performance, accessibility, GEO)
97
+ - Research visiting more than 1-2 web pages
98
+ - Code reviews on more than a single file
99
+ - Full-repo analyses, dependency scans, architecture proposals
90
100
  - Report generation with multiple sub-steps
91
- - Anything you estimate will take longer than 2 minutes
101
+ - Build / install / test runs
102
+ - Long data-processing jobs
103
+ - Anything involving the word "analyze", "audit", "review", "scan", "research"
92
104
 
93
- **DON'T use run_in_background for:**
94
- - Simple questions the user is actively waiting on a quick answer
95
- - Single file reads
96
- - Quick web fetches for a specific fact
97
- - Short tool chains under ~30 seconds
105
+ **Examples where foreground is fine:**
106
+ - "Read this file and summarize it" (single file, <10s)
107
+ - "What's 2+2?" (no sub-agent needed — answer yourself)
108
+ - "Check if package.json has foo" (one quick tool call)
109
+
110
+ **After launching a background agent, you MUST:**
111
+ 1. Tell the user in ONE short sentence what you kicked off.
112
+ Example: "Starting SEO audit for gethomes.io in the background —
113
+ I'll send the report when it's done."
114
+ 2. End your turn IMMEDIATELY. Do not continue working. Do not wait.
115
+ 3. The bot will deliver the result as a separate message when ready.
116
+ You don't need to poll the outputFile proactively.
117
+
118
+ If the user asks "is it done yet?" before the bot delivers the result,
119
+ you MAY read the agent's \`outputFile\` (from the original tool result)
120
+ using the Read tool to peek at progress — but don't block on it.
98
121
 
99
- When in doubt: prefer background for audits/research, foreground for
100
- conversational answers.`;
122
+ **Never** call the Agent/Task tool without \`run_in_background: true\`
123
+ for anything you're not 100% sure completes in under 30 seconds. The
124
+ cost of unnecessary background mode is zero. The cost of blocking the
125
+ Telegram user for 20 minutes on a synchronous call is very high.`;
101
126
  /**
102
127
  * Self-Awareness Core — Dynamic introspection block.
103
128
  *
@@ -21,6 +21,7 @@
21
21
  import fs from "fs";
22
22
  import { dirname } from "path";
23
23
  import { SESSIONS_STATE_FILE } from "../paths.js";
24
+ import { SECURE_MODE } from "./file-permissions.js";
24
25
  import { getAllSessions, getTelegramWorkspacesMap, } from "./session.js";
25
26
  /** History entries to keep in the persisted snapshot (per session). */
26
27
  const MAX_PERSISTED_HISTORY = 50;
@@ -85,9 +86,20 @@ export async function flushSessions() {
85
86
  sessions: out,
86
87
  telegramWorkspaces: tgWorkspaces,
87
88
  };
88
- // Atomic write: tmp + rename
89
+ // Atomic write: tmp + rename. v4.12.2 — mode 0o600 enforced so other
90
+ // users on the same machine can't read conversation history or tokens.
89
91
  const tmpFile = `${SESSIONS_STATE_FILE}.tmp`;
90
- fs.writeFileSync(tmpFile, JSON.stringify(envelope, null, 2), "utf-8");
92
+ fs.writeFileSync(tmpFile, JSON.stringify(envelope, null, 2), {
93
+ encoding: "utf-8",
94
+ mode: SECURE_MODE,
95
+ });
96
+ // Belt-and-suspenders: chmod in case the tmp file already existed with
97
+ // looser permissions (writeFileSync's mode option is only applied on
98
+ // initial create).
99
+ try {
100
+ fs.chmodSync(tmpFile, SECURE_MODE);
101
+ }
102
+ catch { /* fs may not support */ }
91
103
  fs.renameSync(tmpFile, SESSIONS_STATE_FILE);
92
104
  }
93
105
  catch (err) {
@@ -250,12 +250,30 @@ async function runSubAgent(id, agentConfig, abort, resolvedName) {
250
250
  ? agentConfig.workingDir || os.homedir()
251
251
  : os.homedir();
252
252
  const systemPrompt = `You are a sub-agent named "${resolvedName}". Complete the following task autonomously and report your results clearly when done. Working directory: ${effectiveCwd}`;
253
+ // v4.12.2 — Map the toolset preset to an explicit allowedTools list.
254
+ // The provider honors this override (see src/providers/claude-sdk-provider.ts
255
+ // line ~140). Passing undefined = full access (provider default).
256
+ const allowedToolsForToolset = (preset) => {
257
+ switch (preset) {
258
+ case "readonly":
259
+ // Read, analyze, search — no writes, no shell, no network.
260
+ return ["Read", "Glob", "Grep"];
261
+ case "research":
262
+ // Same as readonly + web access for research tasks.
263
+ return ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
264
+ case "full":
265
+ default:
266
+ // undefined → provider uses its full default set.
267
+ return undefined;
268
+ }
269
+ };
253
270
  for await (const chunk of registry.queryWithFallback({
254
271
  prompt: agentConfig.prompt,
255
272
  systemPrompt,
256
273
  workingDir: effectiveCwd,
257
274
  effort: "high",
258
275
  abortSignal: abort.signal,
276
+ allowedTools: allowedToolsForToolset(agentConfig.toolset ?? "full"),
259
277
  })) {
260
278
  if (chunk.type === "text") {
261
279
  // Both SDK providers emit `text` as the accumulated string.
@@ -483,12 +501,12 @@ export function spawnSubAgent(agentConfig) {
483
501
  if (depth > MAX_SUBAGENT_DEPTH) {
484
502
  return Promise.reject(new Error(`Sub-agent depth limit reached (${MAX_SUBAGENT_DEPTH}). Agents can only spawn ${MAX_SUBAGENT_DEPTH} level(s) of nested agents.`));
485
503
  }
486
- // G1: toolset preset. Only "full" is supported. The literal type blocks
487
- // wrong values at compile time; the runtime check catches callers that
488
- // bypass TypeScript (e.g. plugin code loaded at runtime).
504
+ // G1: toolset preset (v4.12.2 extended with readonly + research).
505
+ // The literal type constrains at compile time; the runtime check catches
506
+ // callers that bypass TypeScript (e.g. plugin code loaded at runtime).
489
507
  const toolset = agentConfig.toolset ?? "full";
490
- if (toolset !== "full") {
491
- return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
508
+ if (toolset !== "full" && toolset !== "readonly" && toolset !== "research") {
509
+ return Promise.reject(new Error(`Invalid toolset "${toolset}". Valid presets: full, readonly, research.`));
492
510
  }
493
511
  const maxParallel = getMaxParallelAgents();
494
512
  const queueCap = getQueueCap();
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Timing-Safe Bearer Token Comparison (v4.12.2)
3
+ *
4
+ * Replaces naive `authHeader !== "Bearer " + token` comparison with
5
+ * crypto.timingSafeEqual so that token comparison time doesn't leak
6
+ * character-level information via side-channel.
7
+ *
8
+ * Real-world exploitability over network is low due to network jitter,
9
+ * but this is the right tool regardless — defense in depth.
10
+ *
11
+ * Behavior:
12
+ * - Strict "Bearer <token>" format required (exactly one space)
13
+ * - Empty expected token always rejects (prevents accidental auth bypass)
14
+ * - Different-length tokens compared via timingSafeEqual on padded buffers
15
+ * so timing doesn't leak whether the prefix matched
16
+ * - Unicode-safe: Buffer.from uses UTF-8 encoding
17
+ */
18
+ import { timingSafeEqual } from "crypto";
19
+ export function timingSafeBearerMatch(authHeader, expectedToken) {
20
+ // Empty expected token → always reject. Prevents a misconfig where
21
+ // config.webhookToken is "" from accidentally allowing any "Bearer "
22
+ // or empty Authorization header.
23
+ if (!expectedToken || expectedToken.length === 0)
24
+ return false;
25
+ // Missing or non-string header
26
+ if (!authHeader || typeof authHeader !== "string")
27
+ return false;
28
+ // Strict format: "Bearer <token>" with exactly one space. Anything else
29
+ // (double space, leading whitespace, wrong prefix) is rejected. We do
30
+ // this via startsWith + exact-length check, not split, so attackers
31
+ // can't use whitespace variations to confuse the parser.
32
+ const prefix = "Bearer ";
33
+ if (!authHeader.startsWith(prefix))
34
+ return false;
35
+ const providedToken = authHeader.slice(prefix.length);
36
+ // timingSafeEqual requires equal-length buffers. If lengths differ,
37
+ // we return false — but we still touch both strings symbolically so
38
+ // the compare itself is constant-time relative to the shorter one.
39
+ // (A length leak through string.length check is acceptable; what we
40
+ // actually care about is that the character-by-character comparison
41
+ // doesn't leak.)
42
+ const expectedBuf = Buffer.from(expectedToken, "utf-8");
43
+ const providedBuf = Buffer.from(providedToken, "utf-8");
44
+ if (expectedBuf.length !== providedBuf.length) {
45
+ // Do a dummy comparison so total time is closer to constant.
46
+ // Not perfect but better than early-return alone.
47
+ timingSafeEqual(expectedBuf, expectedBuf);
48
+ return false;
49
+ }
50
+ return timingSafeEqual(expectedBuf, providedBuf);
51
+ }
@@ -12,6 +12,7 @@ import fs from "fs";
12
12
  import { resolve, dirname } from "path";
13
13
  import { execSync } from "child_process";
14
14
  import { BOT_ROOT, ENV_FILE, BACKUP_DIR, DATA_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, SOUL_EXAMPLE, TOOLS_MD, TOOLS_JSON, CUSTOM_MODELS, CRON_FILE, MCP_CONFIG } from "../paths.js";
15
+ import { writeSecure } from "../services/file-permissions.js";
15
16
  // Files to include in backups (absolute paths)
16
17
  const BACKUP_FILES = [
17
18
  { src: ENV_FILE, label: ".env" },
@@ -222,9 +223,14 @@ function autoRepair(action) {
222
223
  const exampleFile = resolve(BOT_ROOT, ".env.example");
223
224
  if (fs.existsSync(exampleFile)) {
224
225
  fs.copyFileSync(exampleFile, ENV_FILE);
226
+ // v4.12.2 — enforce 0o600 on fresh .env
227
+ try {
228
+ fs.chmodSync(ENV_FILE, 0o600);
229
+ }
230
+ catch { /* fs may not support */ }
225
231
  return { ok: true, message: ".env created from .env.example" };
226
232
  }
227
- fs.writeFileSync(ENV_FILE, "BOT_TOKEN=\nALLOWED_USERS=\nPRIMARY_PROVIDER=claude-sdk\n");
233
+ writeSecure(ENV_FILE, "BOT_TOKEN=\nALLOWED_USERS=\nPRIMARY_PROVIDER=claude-sdk\n");
228
234
  return { ok: true, message: "Default .env created (BOT_TOKEN still needs to be set)" };
229
235
  }
230
236
  case "create-docs": {
@@ -272,7 +278,7 @@ function autoRepair(action) {
272
278
  const lines = fs.readFileSync(ENV_FILE, "utf-8").split("\n");
273
279
  if (lineIdx >= 0 && lineIdx < lines.length) {
274
280
  lines[lineIdx] = "# " + lines[lineIdx]; // Comment out broken line
275
- fs.writeFileSync(ENV_FILE, lines.join("\n"));
281
+ writeSecure(ENV_FILE, lines.join("\n"));
276
282
  return { ok: true, message: `Line ${lineIdx + 1} commented out` };
277
283
  }
278
284
  }