alvin-bot 4.12.1 → 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/CHANGELOG.md +81 -0
- package/README.md +42 -5
- package/dist/index.js +75 -3
- package/dist/providers/claude-sdk-provider.js +4 -1
- package/dist/services/allowed-users-gate.js +56 -0
- package/dist/services/cron.js +17 -0
- package/dist/services/exec-guard.js +26 -1
- package/dist/services/fallback-order.js +4 -1
- package/dist/services/file-permissions.js +93 -0
- package/dist/services/session-persistence.js +14 -2
- package/dist/services/subagents.js +23 -5
- package/dist/services/timing-safe-bearer.js +51 -0
- package/dist/web/doctor-api.js +8 -2
- package/dist/web/server.js +7 -3
- package/dist/web/setup-api.js +5 -2
- package/docs/security.md +279 -0
- package/package.json +4 -1
- package/test/allowed-users-gate.test.ts +98 -0
- package/test/exec-guard-metachars.test.ts +110 -0
- package/test/file-permissions.test.ts +130 -0
- package/test/subagent-toolset-allowlist.test.ts +146 -0
- package/test/subagents-toolset.test.ts +22 -2
- package/test/timing-safe-bearer.test.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,87 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.12.2] — 2026-04-15
|
|
6
|
+
|
|
7
|
+
### 🔒 Security patch: file permissions, ALLOWED_USERS hard-fail, exec-guard hardening, CVE updates
|
|
8
|
+
|
|
9
|
+
This is the first **formal security release** of Alvin Bot, motivated by a comprehensive audit after v4.12.1 production deployment. The audit surfaced real issues that needed fixing before the bot could be safely installed on multi-user dev servers or shared by external users. All fixes are additive and backwards-compatible — existing single-user installs see no behavior change except improved security.
|
|
10
|
+
|
|
11
|
+
#### CRITICAL CVE — axios 1.14.0 → 1.15.0 (CVSS 10.0)
|
|
12
|
+
|
|
13
|
+
Transitive dependency via `@slack/bolt`. Two CVEs closed:
|
|
14
|
+
- GHSA-fvcv-3m26-pcqx — Cloud Metadata Exfiltration via Header Injection Chain (CVSS 10.0)
|
|
15
|
+
- GHSA-3p68-rc4w-qgx5 — NO_PROXY Hostname Normalization Bypass → SSRF
|
|
16
|
+
|
|
17
|
+
Fix: `npm update @slack/bolt` (4.6.0 → 4.7.0) + `package.json overrides: axios ^1.15.0` to force transitive updates in `@slack/web-api` and `@whiskeysockets/baileys`. Post-fix `npm audit` shows **0 critical, 2 high remaining** (`basic-ftp` HIGH — never invoked by Alvin, `electron` HIGH — devDep only, tracked as Phase 18).
|
|
18
|
+
|
|
19
|
+
Also updated `@anthropic-ai/claude-agent-sdk` 0.2.97 → 0.2.109 (MODERATE: GHSA-5474-4w2j-mq4c Path Validation Sandbox Escape).
|
|
20
|
+
|
|
21
|
+
#### CRITICAL — File permissions on sensitive files (0o600)
|
|
22
|
+
|
|
23
|
+
Pre-v4.12.2 `~/.alvin-bot/.env`, `state/sessions.json`, memory logs, cron-jobs.json were written with the default umask — typically 0o644 on Linux/macOS, meaning any other user on the same machine could read BOT_TOKEN + all API keys, full conversation history, cron prompts, and encrypted sudo credentials.
|
|
24
|
+
|
|
25
|
+
**Fix**: new `src/services/file-permissions.ts` with `writeSecure()`, `ensureSecureMode()`, `auditSensitiveFiles()`. All `.env` writes in setup-api, doctor-api, server, fallback-order, session-persistence now use `writeSecure()`. Startup audit in `index.ts` chmod-repairs the full sensitive-file list idempotently on every boot.
|
|
26
|
+
|
|
27
|
+
#### CRITICAL — ALLOWED_USERS startup hard-fail
|
|
28
|
+
|
|
29
|
+
Pre-v4.12.2 Alvin started with BOT_TOKEN set but ALLOWED_USERS empty with only a console.warn — leaving the bot "configured but unguarded".
|
|
30
|
+
|
|
31
|
+
**Fix**: new pure gate function `src/services/allowed-users-gate.ts`. `src/index.ts` refuses to start with a clear error message. Two explicit escape hatches: `AUTH_MODE=open` or `ALVIN_INSECURE_ACKNOWLEDGED=1`.
|
|
32
|
+
|
|
33
|
+
#### HIGH — Webhook bearer token timing-safe comparison
|
|
34
|
+
|
|
35
|
+
`src/web/server.ts` POST /api/webhook previously used naive `authHeader !== "Bearer " + token` leaking comparison position via timing side-channel.
|
|
36
|
+
|
|
37
|
+
**Fix**: new `src/services/timing-safe-bearer.ts` wraps `crypto.timingSafeEqual` with strict "Bearer <token>" format, empty-expected rejection, length-mismatch dummy comparison.
|
|
38
|
+
|
|
39
|
+
#### HIGH — Exec-guard shell metacharacter rejection
|
|
40
|
+
|
|
41
|
+
`checkExecAllowed()` only inspected the first word — `echo safe; rm -rf /` passed as "echo". Trivially bypassable via `&&`, `|`, `` ` ``, `$(...)`, redirects.
|
|
42
|
+
|
|
43
|
+
**Fix**: allowlist mode rejects any command containing `;`, `&`, `|`, `` ` ``, `$(...)`, `{...}`, `<`, `>`. Operators who need shell pipelines set `EXEC_SECURITY=full` explicitly.
|
|
44
|
+
|
|
45
|
+
#### HIGH — Cron shell-job execGuard integration
|
|
46
|
+
|
|
47
|
+
Pre-v4.12.2 cron `type: "shell"` bypassed the exec-guard entirely. **Fix**: cron.ts case "shell" now calls `checkExecAllowed()` before `execSync()` and sends a blocked-notification on deny.
|
|
48
|
+
|
|
49
|
+
#### MEDIUM — Sub-agent toolset allowlist (readonly, research)
|
|
50
|
+
|
|
51
|
+
`SubAgentConfig.toolset` widened from `"full"` to `"full" | "readonly" | "research"`:
|
|
52
|
+
- `readonly` → Read, Glob, Grep only (no write, shell, network)
|
|
53
|
+
- `research` → readonly + WebSearch, WebFetch
|
|
54
|
+
- `full` → unchanged default
|
|
55
|
+
|
|
56
|
+
New `QueryOptions.allowedTools?: string[]` honored by `claude-sdk-provider`. Other providers ignore it.
|
|
57
|
+
|
|
58
|
+
#### NEW — `docs/security.md` threat model + hardening guide (279 lines)
|
|
59
|
+
|
|
60
|
+
First formal security documentation covering: TL;DR safety table, capability surface, attacker model, trust boundaries, hardening step-by-step, shell execution policy, file permissions list, sub-agent presets, prompt injection honesty section, Phase 18 pending work, security issue reporting, incident response playbook. Public doc, shipped with the repo.
|
|
61
|
+
|
|
62
|
+
#### NEW — README Security section rewrite
|
|
63
|
+
|
|
64
|
+
Replaced thin bullet list with a boxed warning ("Alvin has full shell + filesystem access") and four sub-sections: access control, execution hardening, data hardening, known limitations. Links to docs/security.md.
|
|
65
|
+
|
|
66
|
+
#### Testing
|
|
67
|
+
|
|
68
|
+
**396 tests total** (350 baseline from v4.12.1 + 46 new). All green. Build clean.
|
|
69
|
+
|
|
70
|
+
- 10 `test/file-permissions.test.ts`
|
|
71
|
+
- 7 `test/allowed-users-gate.test.ts`
|
|
72
|
+
- 10 `test/timing-safe-bearer.test.ts`
|
|
73
|
+
- 13 `test/exec-guard-metachars.test.ts`
|
|
74
|
+
- 4 `test/subagent-toolset-allowlist.test.ts`
|
|
75
|
+
- 2 extended `test/subagents-toolset.test.ts` (readonly + research)
|
|
76
|
+
|
|
77
|
+
#### Phase 18 (deferred, tracked in README Roadmap)
|
|
78
|
+
|
|
79
|
+
- Electron 35 → 41+ upgrade (Desktop build, 6 CVEs)
|
|
80
|
+
- Prompt injection defense strategy (design debate, not code filter)
|
|
81
|
+
- TypeScript 5 → 6 upgrade
|
|
82
|
+
- MCP plugin sandboxing (architectural v5.0)
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
5
86
|
## [4.12.1] — 2026-04-15
|
|
6
87
|
|
|
7
88
|
### 🐛 Patch: Sync sub-agent timeout + workspace command menu
|
package/README.md
CHANGED
|
@@ -659,17 +659,54 @@ alvin-bot version # Show version
|
|
|
659
659
|
- [ ] Workspace cloning / templates — `/workspace clone alev-b as homes-dev` spins up a new workspace from an existing one
|
|
660
660
|
- [ ] Slack slash commands (`/alvin workspace`, `/alvin status`, `/alvin new`) — native Slack command integration via Bolt
|
|
661
661
|
- [ ] Daily log decay / archive — older daily logs move to cold storage after N days
|
|
662
|
+
- [ ] **Phase 18** — Security + Platform hardening (from v4.12.1 audit, prioritized)
|
|
663
|
+
- [ ] **P1 — Electron major upgrade** (35 → 41+) — fixes 1 HIGH + 5 MODERATE Electron CVEs in the Desktop-Build path. Major version jump, requires full rebuild + test of `.dmg` flow. Separate release (likely bundled with Windows `.exe` work).
|
|
664
|
+
- [ ] **P1 — Prompt injection defense strategy** — not a single fix but a design debate: heuristic filters vs allow-list vs no-sandbox-accept-the-risk. Currently handled as a documented design-constraint (README security section), not as a code filter. When we decide the policy, implement it across all message entry points.
|
|
665
|
+
- [ ] **P2 — TypeScript 5 → 6 upgrade** — major release, likely breaking changes in strict mode. Needs a dedicated release + test sweep. Low priority since 5.x is still supported.
|
|
666
|
+
- [ ] **P0 for v5.0 — MCP plugin sandboxing** — currently MCP servers run with full Node privileges. Plan: run each MCP in a child process with restricted FS + network policy (similar to deno-permission model). Architectural change, v5.0 territory.
|
|
662
667
|
|
|
663
668
|
---
|
|
664
669
|
|
|
665
670
|
## 🔒 Security
|
|
666
671
|
|
|
667
|
-
|
|
672
|
+
> ### ⚠️ Important: Alvin has full shell + filesystem access
|
|
673
|
+
>
|
|
674
|
+
> Alvin Bot is an **autonomous AI agent** built on the Claude Agent SDK with shell, filesystem, and network access to the machine it runs on. This is by design — it's the point of the project. But it means:
|
|
675
|
+
>
|
|
676
|
+
> - **Treat the bot like `sudo` access** — only install it on machines where you'd trust Claude Code to run without supervision.
|
|
677
|
+
> - **Never expose the Web UI (port 3100) to the internet** without HTTPS, rate limiting, and a strong `WEB_PASSWORD`. It binds to `localhost` by default.
|
|
678
|
+
> - **On multi-user systems**, verify `~/.alvin-bot/.env` is chmod `600` (v4.12.2+ enforces this automatically on startup).
|
|
679
|
+
> - **`ALLOWED_USERS` is your first line of defense** — v4.12.2+ refuses to start if it's empty and Telegram is enabled.
|
|
680
|
+
>
|
|
681
|
+
> **Read the full threat model and hardening guide:** [`docs/security.md`](docs/security.md)
|
|
682
|
+
|
|
683
|
+
### Access control
|
|
684
|
+
|
|
685
|
+
- **User whitelist** — Only `ALLOWED_USERS` can interact with the bot (hard-enforced at startup since v4.12.2)
|
|
668
686
|
- **WhatsApp group approval** — Per-group participant whitelist + owner approval gate via Telegram (with WhatsApp DM / Discord / Signal fallback). Group members never see the approval process.
|
|
669
|
-
- **
|
|
670
|
-
- **
|
|
671
|
-
|
|
672
|
-
|
|
687
|
+
- **Slack allowlist** — `SLACK_ALLOWED_USERS` restricts who can DM or @mention the bot in Slack
|
|
688
|
+
- **DM pairing** — Optional 6-digit code flow for new users via owner approval (`AUTH_MODE=pairing`)
|
|
689
|
+
|
|
690
|
+
### Execution hardening
|
|
691
|
+
|
|
692
|
+
- **`EXEC_SECURITY=allowlist`** (default) — Shell commands must match a whitelist of safe binaries and **cannot contain shell metacharacters** (`;`, `|`, `&`, `` ` ``, `$(...)`, redirects). Rejected by v4.12.2's exec-guard metachar filter.
|
|
693
|
+
- **Cron shell jobs** go through the same exec-guard (v4.12.2+) — cron is no longer a bypass vector.
|
|
694
|
+
- **Sub-agent toolset presets** — spawn sub-agents with `toolset: "readonly"` or `"research"` to restrict what they can do, regardless of the parent's privileges.
|
|
695
|
+
- **Timing-safe webhook auth** — `POST /api/webhook` uses `crypto.timingSafeEqual` (v4.12.2+) to prevent timing side-channel token extraction.
|
|
696
|
+
|
|
697
|
+
### Data hardening
|
|
698
|
+
|
|
699
|
+
- **Self-hosted** — Your data stays on your machine. No cloud sync, no external logging of prompts or responses.
|
|
700
|
+
- **No telemetry** — Zero tracking, zero analytics, zero phone-home.
|
|
701
|
+
- **File permissions** — `.env`, `sessions.json`, memory logs, cron jobs, and all sensitive state files are chmod `0o600` on every write and repaired at startup (v4.12.2+).
|
|
702
|
+
- **Owner protection** — Owner account cannot be deleted via UI.
|
|
703
|
+
- **Encrypted sudo credentials** — If you enable sudo exec, passwords are stored encrypted with an XOR key in a separate file, both chmod `0o600`.
|
|
704
|
+
|
|
705
|
+
### Known limitations (documented honestly)
|
|
706
|
+
|
|
707
|
+
- **Prompt injection** cannot be reliably filtered — we document this as a capability tradeoff rather than pretending to solve it. See `docs/security.md` for the full discussion.
|
|
708
|
+
- **Not yet hardened for public-internet deployment** — current scope is "on your own machine". VPS deployment works but requires additional reverse-proxy + TLS + rate-limit setup that we don't automate.
|
|
709
|
+
- **Electron Desktop build** has known CVEs (Phase 18 roadmap). The primary distribution is npm global install, not Desktop — if you don't use the Desktop wrapper, you're not affected.
|
|
673
710
|
|
|
674
711
|
---
|
|
675
712
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
],
|
|
@@ -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
|
+
}
|
package/dist/services/cron.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -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),
|
|
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.
|
|
487
|
-
//
|
|
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}".
|
|
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
|
+
}
|