diff --git a/.github/labeler.yml b/.github/labeler.yml
index 6e4f74306..f22868736 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -133,6 +133,17 @@
- "docs/**"
- "docs.acp.md"
+"cli":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/cli/**"
+
+"security":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "docs/cli/security.md"
+ - "docs/gateway/security.md"
+
"extensions: copilot-proxy":
- changed-files:
- any-glob-to-any-file:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f0d77860..16c1a05ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,12 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
+- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
+- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
+- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
+- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
@@ -16,9 +20,10 @@ Status: unreleased.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
-- Docs: add LINE channel guide.
+- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
+- Onboarding: strengthen security warning copy for beta + access control expectations.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
@@ -27,7 +32,9 @@ Status: unreleased.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
+- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
+- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
@@ -36,18 +43,26 @@ Status: unreleased.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
+### Breaking
+- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
+
### Fixes
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
+- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile.
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
+- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
+- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
+- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
## 2026.1.24-3
### Fixes
+- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen.
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
diff --git a/README.md b/README.md
index 217a4b61c..535cd1c75 100644
--- a/README.md
+++ b/README.md
@@ -479,32 +479,33 @@ Thanks to all clawtributors:
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/cli/index.md b/docs/cli/index.md
index d23ee3a5e..9a72322e2 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -314,7 +314,7 @@ Options:
- `--opencode-zen-api-key `
- `--gateway-port `
- `--gateway-bind `
-- `--gateway-auth `
+- `--gateway-auth `
- `--gateway-token `
- `--gateway-password `
- `--remote-url `
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 024c0b1c5..8db2844fd 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -2847,9 +2847,11 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).
-- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
- device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
+- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when
+ device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS
(Tailscale Serve) or `127.0.0.1`.
+- `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks for the
+ Control UI (token/password only). Default: `false`. Break-glass only.
Related docs:
- [Control UI](/web/control-ui)
diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md
index fc6682708..279b37614 100644
--- a/docs/gateway/protocol.md
+++ b/docs/gateway/protocol.md
@@ -198,7 +198,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
- **Local** connects include loopback and the gateway host’s own tailnet address
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node).
- Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
+ Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled
+ (or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use).
- Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index ce542951d..564b248fe 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -58,9 +58,13 @@ When the audit prints findings, treat this as a priority order:
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
-to **token-only auth** and skips device pairing (even on HTTPS). This is a security
+to **token-only auth** and skips device pairing when device identity is omitted. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
+For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth`
+disables device identity checks entirely. This is a severe security downgrade;
+keep it off unless you are actively debugging and can revert quickly.
+
`clawdbot security audit` warns when this setting is enabled.
## Reverse Proxy Configuration
@@ -193,10 +197,17 @@ Prompt injection is when an attacker crafts a message that manipulates the model
Even with strong system prompts, **prompt injection is not solved**. What helps in practice:
- Keep inbound DMs locked down (pairing/allowlists).
- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
-- Treat links and pasted instructions as hostile by default.
+- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
+- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
+Red flags to treat as untrusted:
+- “Read this file/URL and do exactly what it says.”
+- “Ignore your system prompt or safety rules.”
+- “Reveal your hidden instructions or tool outputs.”
+- “Paste the full contents of ~/.clawdbot or your logs.”
+
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via
@@ -210,6 +221,7 @@ tool calls. Reduce the blast radius by:
then pass the summary to your main agent.
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
+- Keeping secrets out of prompts; pass them via env/config on the gateway host instead.
### Model strength (security note)
@@ -226,8 +238,12 @@ Recommendations:
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
was not meant for a public channel. In group settings, treat them as **debug
-only** and keep them off unless you explicitly need them. If you enable them,
-do so only in trusted DMs or tightly controlled rooms.
+only** and keep them off unless you explicitly need them.
+
+Guidance:
+- Keep `/reasoning` and `/verbose` disabled in public rooms.
+- If you enable them, do so only in trusted DMs or tightly controlled rooms.
+- Remember: verbose output can include tool args, URLs, and data the model saw.
## Incident Response (if you suspect compromise)
@@ -544,6 +560,7 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
+- Prefer env vars for the token (`CLAWDBOT_BROWSER_CONTROL_TOKEN`) instead of storing it in config on disk.
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
## Per-agent access profiles (multi-agent)
diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md
index 24815e258..5cbffd815 100644
--- a/docs/gateway/troubleshooting.md
+++ b/docs/gateway/troubleshooting.md
@@ -214,7 +214,7 @@ the Gateway likely refused to bind.
- Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite).
**If `Last gateway error:` mentions “refusing to bind … without auth”**
-- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off.
+- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but didn’t configure auth.
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service.
**If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found**
diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md
index 0fdf176ae..dee731ea7 100644
--- a/docs/platforms/fly.md
+++ b/docs/platforms/fly.md
@@ -39,7 +39,9 @@ fly volumes create clawdbot_data --size 1 --region iad
## 2) Configure fly.toml
-Edit `fly.toml` to match your app name and requirements:
+Edit `fly.toml` to match your app name and requirements.
+
+**Security note:** The default config exposes a public URL. For a hardened deployment with no public IP, see [Private Deployment](#private-deployment-hardened) or use `fly.private.toml`.
```toml
app = "my-clawdbot" # Your app name
@@ -104,6 +106,7 @@ fly secrets set DISCORD_BOT_TOKEN=MTQ...
**Notes:**
- Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security.
- Treat these tokens like passwords.
+- **Prefer env vars over config file** for all API keys and tokens. This keeps secrets out of `clawdbot.json` where they could be accidentally exposed or logged.
## 4) Deploy
@@ -337,6 +340,114 @@ fly machine update --vm-memory 2048 --command "node dist/index.js g
**Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy.
+## Private Deployment (Hardened)
+
+By default, Fly allocates public IPs, making your gateway accessible at `https://your-app.fly.dev`. This is convenient but means your deployment is discoverable by internet scanners (Shodan, Censys, etc.).
+
+For a hardened deployment with **no public exposure**, use the private template.
+
+### When to use private deployment
+
+- You only make **outbound** calls/messages (no inbound webhooks)
+- You use **ngrok or Tailscale** tunnels for any webhook callbacks
+- You access the gateway via **SSH, proxy, or WireGuard** instead of browser
+- You want the deployment **hidden from internet scanners**
+
+### Setup
+
+Use `fly.private.toml` instead of the standard config:
+
+```bash
+# Deploy with private config
+fly deploy -c fly.private.toml
+```
+
+Or convert an existing deployment:
+
+```bash
+# List current IPs
+fly ips list -a my-clawdbot
+
+# Release public IPs
+fly ips release -a my-clawdbot
+fly ips release -a my-clawdbot
+
+# Switch to private config so future deploys don't re-allocate public IPs
+# (remove [http_service] or deploy with the private template)
+fly deploy -c fly.private.toml
+
+# Allocate private-only IPv6
+fly ips allocate-v6 --private -a my-clawdbot
+```
+
+After this, `fly ips list` should show only a `private` type IP:
+```
+VERSION IP TYPE REGION
+v6 fdaa:x:x:x:x::x private global
+```
+
+### Accessing a private deployment
+
+Since there's no public URL, use one of these methods:
+
+**Option 1: Local proxy (simplest)**
+```bash
+# Forward local port 3000 to the app
+fly proxy 3000:3000 -a my-clawdbot
+
+# Then open http://localhost:3000 in browser
+```
+
+**Option 2: WireGuard VPN**
+```bash
+# Create WireGuard config (one-time)
+fly wireguard create
+
+# Import to WireGuard client, then access via internal IPv6
+# Example: http://[fdaa:x:x:x:x::x]:3000
+```
+
+**Option 3: SSH only**
+```bash
+fly ssh console -a my-clawdbot
+```
+
+### Webhooks with private deployment
+
+If you need webhook callbacks (Twilio, Telnyx, etc.) without public exposure:
+
+1. **ngrok tunnel** - Run ngrok inside the container or as a sidecar
+2. **Tailscale Funnel** - Expose specific paths via Tailscale
+3. **Outbound-only** - Some providers (Twilio) work fine for outbound calls without webhooks
+
+Example voice-call config with ngrok:
+```json
+{
+ "plugins": {
+ "entries": {
+ "voice-call": {
+ "enabled": true,
+ "config": {
+ "provider": "twilio",
+ "tunnel": { "provider": "ngrok" }
+ }
+ }
+ }
+ }
+}
+```
+
+The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself.
+
+### Security benefits
+
+| Aspect | Public | Private |
+|--------|--------|---------|
+| Internet scanners | Discoverable | Hidden |
+| Direct attacks | Possible | Blocked |
+| Control UI access | Browser | Proxy/VPN |
+| Webhook delivery | Direct | Via tunnel |
+
## Notes
- Fly.io uses **x86 architecture** (not ARM)
diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md
index eecb80133..cd574b26e 100644
--- a/docs/plugins/voice-call.md
+++ b/docs/plugins/voice-call.md
@@ -103,6 +103,8 @@ Notes:
- Plivo requires a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only.
+- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced.
+- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## TTS for calls
diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts
index 7334498e2..aac9fe44c 100644
--- a/extensions/voice-call/src/config.test.ts
+++ b/extensions/voice-call/src/config.test.ts
@@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
- tunnel: { provider: "none", allowNgrokFreeTier: true },
+ tunnel: { provider: "none", allowNgrokFreeTier: false },
streaming: {
enabled: false,
sttProvider: "openai-realtime",
diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts
index 6d6036792..99916e49d 100644
--- a/extensions/voice-call/src/config.ts
+++ b/extensions/voice-call/src/config.ts
@@ -217,13 +217,12 @@ export const VoiceCallTunnelConfigSchema = z
/**
* Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs
- * will be logged but allowed through. Less secure, but necessary
- * for ngrok free tier which may modify URLs.
+ * will include extra diagnostics. Signature verification is still required.
*/
- allowNgrokFreeTier: z.boolean().default(true),
+ allowNgrokFreeTier: z.boolean().default(false),
})
.strict()
- .default({ provider: "none", allowNgrokFreeTier: true });
+ .default({ provider: "none", allowNgrokFreeTier: false });
export type VoiceCallTunnelConfig = z.infer;
// -----------------------------------------------------------------------------
@@ -418,11 +417,14 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
}
// Tunnel Config
- resolved.tunnel = resolved.tunnel ?? { provider: "none", allowNgrokFreeTier: true };
+ resolved.tunnel = resolved.tunnel ?? {
+ provider: "none",
+ allowNgrokFreeTier: false,
+ };
resolved.tunnel.ngrokAuthToken =
- resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
- resolved.tunnel.ngrokDomain =
- resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
+ resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
+ resolved.tunnel.ngrokDomain =
+ resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
return resolved;
}
diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts
index 28f445c88..1cddcb164 100644
--- a/extensions/voice-call/src/providers/twilio/webhook.ts
+++ b/extensions/voice-call/src/providers/twilio/webhook.ts
@@ -11,7 +11,7 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined,
- allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? true,
+ allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false,
skipVerification: params.options.skipVerification,
});
diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts
index a2eb15315..ffa95ddff 100644
--- a/extensions/voice-call/src/runtime.ts
+++ b/extensions/voice-call/src/runtime.ts
@@ -48,7 +48,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken,
},
{
- allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true,
+ allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false,
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled
diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts
index c31d7225a..98d8a451c 100644
--- a/extensions/voice-call/src/webhook-security.test.ts
+++ b/extensions/voice-call/src/webhook-security.test.ts
@@ -205,4 +205,29 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(true);
});
+
+ it("rejects invalid signatures even with ngrok free tier enabled", () => {
+ const authToken = "test-auth-token";
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
+
+ const result = verifyTwilioWebhook(
+ {
+ headers: {
+ host: "127.0.0.1:3334",
+ "x-forwarded-proto": "https",
+ "x-forwarded-host": "attacker.ngrok-free.app",
+ "x-twilio-signature": "invalid",
+ },
+ rawBody: postBody,
+ url: "http://127.0.0.1:3334/voice/webhook",
+ method: "POST",
+ },
+ authToken,
+ { allowNgrokFreeTier: true },
+ );
+
+ expect(result.ok).toBe(false);
+ expect(result.isNgrokFreeTier).toBe(true);
+ expect(result.reason).toMatch(/Invalid signature/);
+ });
});
diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts
index 79bd96099..98b1d9837 100644
--- a/extensions/voice-call/src/webhook-security.ts
+++ b/extensions/voice-call/src/webhook-security.ts
@@ -195,18 +195,6 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io");
- if (isNgrokFreeTier && options?.allowNgrokFreeTier) {
- console.warn(
- "[voice-call] Twilio signature validation failed (proceeding for ngrok free tier compatibility)",
- );
- return {
- ok: true,
- reason: "ngrok free tier compatibility mode",
- verificationUrl,
- isNgrokFreeTier: true,
- };
- }
-
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
diff --git a/fly.private.toml b/fly.private.toml
new file mode 100644
index 000000000..6edbc8005
--- /dev/null
+++ b/fly.private.toml
@@ -0,0 +1,39 @@
+# Clawdbot Fly.io PRIVATE deployment configuration
+# Use this template for hardened deployments with no public IP exposure.
+#
+# This config is suitable when:
+# - You only make outbound calls (no inbound webhooks needed)
+# - You use ngrok/Tailscale tunnels for any webhook callbacks
+# - You access the gateway via `fly proxy` or WireGuard, not public URL
+# - You want the deployment hidden from internet scanners (Shodan, etc.)
+#
+# See https://fly.io/docs/reference/configuration/
+
+app = "my-clawdbot" # change to your app name
+primary_region = "iad" # change to your closest region
+
+[build]
+ dockerfile = "Dockerfile"
+
+[env]
+ NODE_ENV = "production"
+ CLAWDBOT_PREFER_PNPM = "1"
+ CLAWDBOT_STATE_DIR = "/data"
+ NODE_OPTIONS = "--max-old-space-size=1536"
+
+[processes]
+ app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
+
+# NOTE: No [http_service] block = no public ingress allocated.
+# The gateway will only be accessible via:
+# - fly proxy 3000:3000 -a
+# - fly wireguard (then access via internal IPv6)
+# - fly ssh console
+
+[[vm]]
+ size = "shared-cpu-2x"
+ memory = "2048mb"
+
+[mounts]
+ source = "clawdbot_data"
+ destination = "/data"
diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts
new file mode 100644
index 000000000..43202bbb5
--- /dev/null
+++ b/src/agents/pi-tools.safe-bins.test.ts
@@ -0,0 +1,78 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it, vi } from "vitest";
+import type { ClawdbotConfig } from "../config/config.js";
+import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
+import { createClawdbotCodingTools } from "./pi-tools.js";
+
+vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
+ const mod = await importOriginal();
+ const approvals: ExecApprovalsResolved = {
+ path: "/tmp/exec-approvals.json",
+ socketPath: "/tmp/exec-approvals.sock",
+ token: "token",
+ defaults: {
+ security: "allowlist",
+ ask: "off",
+ askFallback: "deny",
+ autoAllowSkills: false,
+ },
+ agent: {
+ security: "allowlist",
+ ask: "off",
+ askFallback: "deny",
+ autoAllowSkills: false,
+ },
+ allowlist: [],
+ file: {
+ version: 1,
+ socket: { path: "/tmp/exec-approvals.sock", token: "token" },
+ defaults: {
+ security: "allowlist",
+ ask: "off",
+ askFallback: "deny",
+ autoAllowSkills: false,
+ },
+ agents: {},
+ },
+ };
+ return { ...mod, resolveExecApprovals: () => approvals };
+});
+
+describe("createClawdbotCodingTools safeBins", () => {
+ it("threads tools.exec.safeBins into exec allowlist checks", async () => {
+ if (process.platform === "win32") return;
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-"));
+ const cfg: ClawdbotConfig = {
+ tools: {
+ exec: {
+ host: "gateway",
+ security: "allowlist",
+ ask: "off",
+ safeBins: ["echo"],
+ },
+ },
+ };
+
+ const tools = createClawdbotCodingTools({
+ config: cfg,
+ sessionKey: "agent:main:main",
+ workspaceDir: tmpDir,
+ agentDir: path.join(tmpDir, "agent"),
+ });
+ const execTool = tools.find((tool) => tool.name === "exec");
+ expect(execTool).toBeDefined();
+
+ const marker = `safe-bins-${Date.now()}`;
+ const result = await execTool!.execute("call1", {
+ command: `echo ${marker}`,
+ workdir: tmpDir,
+ });
+ const text = result.content.find((content) => content.type === "text")?.text ?? "";
+
+ expect(result.details.status).toBe("completed");
+ expect(text).toContain(marker);
+ });
+});
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index bd745da03..9013f1e52 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
ask: globalExec?.ask,
node: globalExec?.node,
pathPrepend: globalExec?.pathPrepend,
+ safeBins: globalExec?.safeBins,
backgroundMs: globalExec?.backgroundMs,
timeoutSec: globalExec?.timeoutSec,
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
@@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: {
ask: options?.exec?.ask ?? execConfig.ask,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
+ safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
agentId,
cwd: options?.workspaceDir,
allowBackground,
diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts
index 0994829bd..26e21c82e 100644
--- a/src/agents/tools/discord-actions-guild.ts
+++ b/src/agents/tools/discord-actions-guild.ts
@@ -1,5 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
+import { getPresence } from "../../discord/monitor/presence-cache.js";
import {
addRoleDiscord,
createChannelDiscord,
@@ -54,7 +55,10 @@ export async function handleDiscordGuildAction(
const member = accountId
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
: await fetchMemberInfoDiscord(guildId, userId);
- return jsonResult({ ok: true, member });
+ const presence = getPresence(accountId, userId);
+ const activities = presence?.activities ?? undefined;
+ const status = presence?.status ?? undefined;
+ return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
}
case "roleInfo": {
if (!isActionEnabled("roleInfo")) {
diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts
index c8bcaa609..9f1e565dd 100644
--- a/src/agents/tools/web-fetch.ts
+++ b/src/agents/tools/web-fetch.ts
@@ -1,7 +1,13 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
-import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js";
+import {
+ closeDispatcher,
+ createPinnedDispatcher,
+ resolvePinnedHostname,
+ SsrFBlockedError,
+} from "../../infra/net/ssrf.js";
+import type { Dispatcher } from "undici";
import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
@@ -167,7 +173,7 @@ async function fetchWithRedirects(params: {
maxRedirects: number;
timeoutSeconds: number;
userAgent: string;
-}): Promise<{ response: Response; finalUrl: string }> {
+}): Promise<{ response: Response; finalUrl: string; dispatcher: Dispatcher }> {
const signal = withTimeout(undefined, params.timeoutSeconds * 1000);
const visited = new Set();
let currentUrl = params.url;
@@ -184,39 +190,50 @@ async function fetchWithRedirects(params: {
throw new Error("Invalid URL: must be http or https");
}
- await assertPublicHostname(parsedUrl.hostname);
-
- const res = await fetch(parsedUrl.toString(), {
- method: "GET",
- headers: {
- Accept: "*/*",
- "User-Agent": params.userAgent,
- "Accept-Language": "en-US,en;q=0.9",
- },
- signal,
- redirect: "manual",
- });
+ const pinned = await resolvePinnedHostname(parsedUrl.hostname);
+ const dispatcher = createPinnedDispatcher(pinned);
+ let res: Response;
+ try {
+ res = await fetch(parsedUrl.toString(), {
+ method: "GET",
+ headers: {
+ Accept: "*/*",
+ "User-Agent": params.userAgent,
+ "Accept-Language": "en-US,en;q=0.9",
+ },
+ signal,
+ redirect: "manual",
+ dispatcher,
+ } as RequestInit);
+ } catch (err) {
+ await closeDispatcher(dispatcher);
+ throw err;
+ }
if (isRedirectStatus(res.status)) {
const location = res.headers.get("location");
if (!location) {
+ await closeDispatcher(dispatcher);
throw new Error(`Redirect missing location header (${res.status})`);
}
redirectCount += 1;
if (redirectCount > params.maxRedirects) {
+ await closeDispatcher(dispatcher);
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
}
const nextUrl = new URL(location, parsedUrl).toString();
if (visited.has(nextUrl)) {
+ await closeDispatcher(dispatcher);
throw new Error("Redirect loop detected");
}
visited.add(nextUrl);
void res.body?.cancel();
+ await closeDispatcher(dispatcher);
currentUrl = nextUrl;
continue;
}
- return { response: res, finalUrl: currentUrl };
+ return { response: res, finalUrl: currentUrl, dispatcher };
}
}
@@ -348,6 +365,7 @@ async function runWebFetch(params: {
const start = Date.now();
let res: Response;
+ let dispatcher: Dispatcher | null = null;
let finalUrl = params.url;
try {
const result = await fetchWithRedirects({
@@ -358,6 +376,7 @@ async function runWebFetch(params: {
});
res = result.response;
finalUrl = result.finalUrl;
+ dispatcher = result.dispatcher;
} catch (error) {
if (error instanceof SsrFBlockedError) {
throw error;
@@ -396,108 +415,112 @@ async function runWebFetch(params: {
throw error;
}
- if (!res.ok) {
- if (params.firecrawlEnabled && params.firecrawlApiKey) {
- const firecrawl = await fetchFirecrawlContent({
- url: params.url,
- extractMode: params.extractMode,
- apiKey: params.firecrawlApiKey,
- baseUrl: params.firecrawlBaseUrl,
- onlyMainContent: params.firecrawlOnlyMainContent,
- maxAgeMs: params.firecrawlMaxAgeMs,
- proxy: params.firecrawlProxy,
- storeInCache: params.firecrawlStoreInCache,
- timeoutSeconds: params.firecrawlTimeoutSeconds,
- });
- const truncated = truncateText(firecrawl.text, params.maxChars);
- const payload = {
- url: params.url,
- finalUrl: firecrawl.finalUrl || finalUrl,
- status: firecrawl.status ?? res.status,
- contentType: "text/markdown",
- title: firecrawl.title,
- extractMode: params.extractMode,
- extractor: "firecrawl",
- truncated: truncated.truncated,
- length: truncated.text.length,
- fetchedAt: new Date().toISOString(),
- tookMs: Date.now() - start,
- text: truncated.text,
- warning: firecrawl.warning,
- };
- writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
- return payload;
- }
- const rawDetail = await readResponseText(res);
- const detail = formatWebFetchErrorDetail({
- detail: rawDetail,
- contentType: res.headers.get("content-type"),
- maxChars: DEFAULT_ERROR_MAX_CHARS,
- });
- throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
- }
-
- const contentType = res.headers.get("content-type") ?? "application/octet-stream";
- const body = await readResponseText(res);
-
- let title: string | undefined;
- let extractor = "raw";
- let text = body;
- if (contentType.includes("text/html")) {
- if (params.readabilityEnabled) {
- const readable = await extractReadableContent({
- html: body,
- url: finalUrl,
- extractMode: params.extractMode,
- });
- if (readable?.text) {
- text = readable.text;
- title = readable.title;
- extractor = "readability";
- } else {
- const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
- if (firecrawl) {
- text = firecrawl.text;
- title = firecrawl.title;
- extractor = "firecrawl";
- } else {
- throw new Error(
- "Web fetch extraction failed: Readability and Firecrawl returned no content.",
- );
- }
+ try {
+ if (!res.ok) {
+ if (params.firecrawlEnabled && params.firecrawlApiKey) {
+ const firecrawl = await fetchFirecrawlContent({
+ url: params.url,
+ extractMode: params.extractMode,
+ apiKey: params.firecrawlApiKey,
+ baseUrl: params.firecrawlBaseUrl,
+ onlyMainContent: params.firecrawlOnlyMainContent,
+ maxAgeMs: params.firecrawlMaxAgeMs,
+ proxy: params.firecrawlProxy,
+ storeInCache: params.firecrawlStoreInCache,
+ timeoutSeconds: params.firecrawlTimeoutSeconds,
+ });
+ const truncated = truncateText(firecrawl.text, params.maxChars);
+ const payload = {
+ url: params.url,
+ finalUrl: firecrawl.finalUrl || finalUrl,
+ status: firecrawl.status ?? res.status,
+ contentType: "text/markdown",
+ title: firecrawl.title,
+ extractMode: params.extractMode,
+ extractor: "firecrawl",
+ truncated: truncated.truncated,
+ length: truncated.text.length,
+ fetchedAt: new Date().toISOString(),
+ tookMs: Date.now() - start,
+ text: truncated.text,
+ warning: firecrawl.warning,
+ };
+ writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
+ return payload;
}
- } else {
- throw new Error(
- "Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
- );
+ const rawDetail = await readResponseText(res);
+ const detail = formatWebFetchErrorDetail({
+ detail: rawDetail,
+ contentType: res.headers.get("content-type"),
+ maxChars: DEFAULT_ERROR_MAX_CHARS,
+ });
+ throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
}
- } else if (contentType.includes("application/json")) {
- try {
- text = JSON.stringify(JSON.parse(body), null, 2);
- extractor = "json";
- } catch {
- text = body;
- extractor = "raw";
- }
- }
- const truncated = truncateText(text, params.maxChars);
- const payload = {
- url: params.url,
- finalUrl,
- status: res.status,
- contentType,
- title,
- extractMode: params.extractMode,
- extractor,
- truncated: truncated.truncated,
- length: truncated.text.length,
- fetchedAt: new Date().toISOString(),
- tookMs: Date.now() - start,
- text: truncated.text,
- };
- writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
- return payload;
+ const contentType = res.headers.get("content-type") ?? "application/octet-stream";
+ const body = await readResponseText(res);
+
+ let title: string | undefined;
+ let extractor = "raw";
+ let text = body;
+ if (contentType.includes("text/html")) {
+ if (params.readabilityEnabled) {
+ const readable = await extractReadableContent({
+ html: body,
+ url: finalUrl,
+ extractMode: params.extractMode,
+ });
+ if (readable?.text) {
+ text = readable.text;
+ title = readable.title;
+ extractor = "readability";
+ } else {
+ const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl });
+ if (firecrawl) {
+ text = firecrawl.text;
+ title = firecrawl.title;
+ extractor = "firecrawl";
+ } else {
+ throw new Error(
+ "Web fetch extraction failed: Readability and Firecrawl returned no content.",
+ );
+ }
+ }
+ } else {
+ throw new Error(
+ "Web fetch extraction failed: Readability disabled and Firecrawl unavailable.",
+ );
+ }
+ } else if (contentType.includes("application/json")) {
+ try {
+ text = JSON.stringify(JSON.parse(body), null, 2);
+ extractor = "json";
+ } catch {
+ text = body;
+ extractor = "raw";
+ }
+ }
+
+ const truncated = truncateText(text, params.maxChars);
+ const payload = {
+ url: params.url,
+ finalUrl,
+ status: res.status,
+ contentType,
+ title,
+ extractMode: params.extractMode,
+ extractor,
+ truncated: truncated.truncated,
+ length: truncated.text.length,
+ fetchedAt: new Date().toISOString(),
+ tookMs: Date.now() - start,
+ text: truncated.text,
+ };
+ writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
+ return payload;
+ } finally {
+ await closeDispatcher(dispatcher);
+ }
}
async function tryFirecrawlFallback(params: {
diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts
new file mode 100644
index 000000000..3bbab0cee
--- /dev/null
+++ b/src/channels/plugins/outbound/telegram.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { ClawdbotConfig } from "../../../config/config.js";
+import { telegramOutbound } from "./telegram.js";
+
+describe("telegramOutbound.sendPayload", () => {
+ it("sends text payload with buttons", async () => {
+ const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" }));
+
+ const result = await telegramOutbound.sendPayload?.({
+ cfg: {} as ClawdbotConfig,
+ to: "telegram:123",
+ text: "ignored",
+ payload: {
+ text: "Hello",
+ channelData: {
+ telegram: {
+ buttons: [[{ text: "Option", callback_data: "/option" }]],
+ },
+ },
+ },
+ deps: { sendTelegram },
+ });
+
+ expect(sendTelegram).toHaveBeenCalledTimes(1);
+ expect(sendTelegram).toHaveBeenCalledWith(
+ "telegram:123",
+ "Hello",
+ expect.objectContaining({
+ buttons: [[{ text: "Option", callback_data: "/option" }]],
+ textMode: "html",
+ }),
+ );
+ expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" });
+ });
+
+ it("sends media payloads and attaches buttons only to first", async () => {
+ const sendTelegram = vi
+ .fn()
+ .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" })
+ .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" });
+
+ const result = await telegramOutbound.sendPayload?.({
+ cfg: {} as ClawdbotConfig,
+ to: "telegram:123",
+ text: "ignored",
+ payload: {
+ text: "Caption",
+ mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
+ channelData: {
+ telegram: {
+ buttons: [[{ text: "Go", callback_data: "/go" }]],
+ },
+ },
+ },
+ deps: { sendTelegram },
+ });
+
+ expect(sendTelegram).toHaveBeenCalledTimes(2);
+ expect(sendTelegram).toHaveBeenNthCalledWith(
+ 1,
+ "telegram:123",
+ "Caption",
+ expect.objectContaining({
+ mediaUrl: "https://example.com/a.png",
+ buttons: [[{ text: "Go", callback_data: "/go" }]],
+ }),
+ );
+ const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined;
+ expect(sendTelegram).toHaveBeenNthCalledWith(
+ 2,
+ "telegram:123",
+ "",
+ expect.objectContaining({
+ mediaUrl: "https://example.com/b.png",
+ }),
+ );
+ expect(secondOpts?.buttons).toBeUndefined();
+ expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" });
+ });
+});
diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts
index 9b138705a..6db7afd28 100644
--- a/src/channels/plugins/outbound/telegram.ts
+++ b/src/channels/plugins/outbound/telegram.ts
@@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) {
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
+
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
@@ -50,4 +51,46 @@ export const telegramOutbound: ChannelOutboundAdapter = {
});
return { channel: "telegram", ...result };
},
+ sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => {
+ const send = deps?.sendTelegram ?? sendMessageTelegram;
+ const replyToMessageId = parseReplyToMessageId(replyToId);
+ const messageThreadId = parseThreadId(threadId);
+ const telegramData = payload.channelData?.telegram as
+ | { buttons?: Array> }
+ | undefined;
+ const text = payload.text ?? "";
+ const mediaUrls = payload.mediaUrls?.length
+ ? payload.mediaUrls
+ : payload.mediaUrl
+ ? [payload.mediaUrl]
+ : [];
+ const baseOpts = {
+ verbose: false,
+ textMode: "html" as const,
+ messageThreadId,
+ replyToMessageId,
+ accountId: accountId ?? undefined,
+ };
+
+ if (mediaUrls.length === 0) {
+ const result = await send(to, text, {
+ ...baseOpts,
+ buttons: telegramData?.buttons,
+ });
+ return { channel: "telegram", ...result };
+ }
+
+ // Telegram allows reply_markup on media; attach buttons only to first send.
+ let finalResult: Awaited> | undefined;
+ for (let i = 0; i < mediaUrls.length; i += 1) {
+ const mediaUrl = mediaUrls[i];
+ const isFirst = i === 0;
+ finalResult = await send(to, isFirst ? text : "", {
+ ...baseOpts,
+ mediaUrl,
+ ...(isFirst ? { buttons: telegramData?.buttons } : {}),
+ });
+ }
+ return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) };
+ },
};
diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts
index c21c5b537..298269398 100644
--- a/src/cli/program/register.onboard.ts
+++ b/src/cli/program/register.onboard.ts
@@ -79,7 +79,7 @@ export function registerOnboardCommand(program: Command) {
.option("--opencode-zen-api-key ", "OpenCode Zen API key")
.option("--gateway-port ", "Gateway port")
.option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom")
- .option("--gateway-auth ", "Gateway auth: off|token|password")
+ .option("--gateway-auth ", "Gateway auth: token|password")
.option("--gateway-token ", "Gateway token (token auth)")
.option("--gateway-password ", "Gateway password (password auth)")
.option("--remote-url ", "Remote Gateway WebSocket URL")
diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts
index 42bca4ca4..2bd5a36b7 100644
--- a/src/cli/security-cli.ts
+++ b/src/cli/security-cli.ts
@@ -87,16 +87,23 @@ export function registerSecurityCli(program: Command) {
lines.push(muted(` ${shortenHomeInString(change)}`));
}
for (const action of fixResult.actions) {
- const mode = action.mode.toString(8).padStart(3, "0");
- if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
- else if (action.skipped)
- lines.push(
- muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
- );
- else if (action.error)
- lines.push(
- muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
- );
+ if (action.kind === "chmod") {
+ const mode = action.mode.toString(8).padStart(3, "0");
+ if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
+ else if (action.skipped)
+ lines.push(
+ muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
+ );
+ else if (action.error)
+ lines.push(
+ muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
+ );
+ continue;
+ }
+ const command = shortenHomeInString(action.command);
+ if (action.ok) lines.push(muted(` ${command}`));
+ else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
+ else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
}
if (fixResult.errors.length > 0) {
for (const err of fixResult.errors) {
diff --git a/src/commands/configure.gateway-auth.test.ts b/src/commands/configure.gateway-auth.test.ts
index 69faad450..26a3729f2 100644
--- a/src/commands/configure.gateway-auth.test.ts
+++ b/src/commands/configure.gateway-auth.test.ts
@@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest";
import { buildGatewayAuthConfig } from "./configure.js";
describe("buildGatewayAuthConfig", () => {
- it("clears token/password when auth is off", () => {
- const result = buildGatewayAuthConfig({
- existing: { mode: "token", token: "abc", password: "secret" },
- mode: "off",
- });
-
- expect(result).toBeUndefined();
- });
-
- it("preserves allowTailscale when auth is off", () => {
+ it("preserves allowTailscale when switching to token", () => {
const result = buildGatewayAuthConfig({
existing: {
- mode: "token",
- token: "abc",
+ mode: "password",
+ password: "secret",
allowTailscale: true,
},
- mode: "off",
+ mode: "token",
+ token: "abc",
});
- expect(result).toEqual({ allowTailscale: true });
+ expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true });
});
it("drops password when switching to token", () => {
diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts
index ad9406195..6d3522ab4 100644
--- a/src/commands/configure.gateway-auth.ts
+++ b/src/commands/configure.gateway-auth.ts
@@ -12,7 +12,7 @@ import {
promptModelAllowlist,
} from "./model-picker.js";
-type GatewayAuthChoice = "off" | "token" | "password";
+type GatewayAuthChoice = "token" | "password";
const ANTHROPIC_OAUTH_MODEL_KEYS = [
"anthropic/claude-opus-4-5",
@@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: {
const base: GatewayAuthConfig = {};
if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale;
- if (params.mode === "off") {
- return Object.keys(base).length > 0 ? base : undefined;
- }
if (params.mode === "token") {
return { ...base, mode: "token", token: params.token };
}
diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts
index ba44c3dcf..d572e54a9 100644
--- a/src/commands/configure.gateway.ts
+++ b/src/commands/configure.gateway.ts
@@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
import { confirm, select, text } from "./configure.shared.js";
import { guardCancel, randomToken } from "./onboard-helpers.js";
-type GatewayAuthChoice = "off" | "token" | "password";
+type GatewayAuthChoice = "token" | "password";
export async function promptGatewayConfig(
cfg: ClawdbotConfig,
@@ -91,11 +91,6 @@ export async function promptGatewayConfig(
await select({
message: "Gateway auth",
options: [
- {
- value: "off",
- label: "Off (loopback only)",
- hint: "Not recommended unless you fully trust local processes",
- },
{ value: "token", label: "Token", hint: "Recommended default" },
{ value: "password", label: "Password" },
],
@@ -165,11 +160,6 @@ export async function promptGatewayConfig(
bind = "loopback";
}
- if (authMode === "off" && bind !== "loopback") {
- note("Non-loopback bind requires auth. Switching to token auth.", "Note");
- authMode = "token";
- }
-
if (tailscaleMode === "funnel" && authMode !== "password") {
note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts
new file mode 100644
index 000000000..460b2b1fe
--- /dev/null
+++ b/src/commands/doctor-security.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ClawdbotConfig } from "../config/config.js";
+
+const note = vi.hoisted(() => vi.fn());
+
+vi.mock("../terminal/note.js", () => ({
+ note,
+}));
+
+vi.mock("../channels/plugins/index.js", () => ({
+ listChannelPlugins: () => [],
+}));
+
+import { noteSecurityWarnings } from "./doctor-security.js";
+
+describe("noteSecurityWarnings gateway exposure", () => {
+ let prevToken: string | undefined;
+ let prevPassword: string | undefined;
+
+ beforeEach(() => {
+ note.mockClear();
+ prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
+ prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD;
+ delete process.env.CLAWDBOT_GATEWAY_TOKEN;
+ delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
+ });
+
+ afterEach(() => {
+ if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
+ else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
+ if (prevPassword === undefined) delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
+ else process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword;
+ });
+
+ const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? "");
+
+ it("warns when exposed without auth", async () => {
+ const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
+ await noteSecurityWarnings(cfg);
+ const message = lastMessage();
+ expect(message).toContain("CRITICAL");
+ expect(message).toContain("without authentication");
+ });
+
+ it("uses env token to avoid critical warning", async () => {
+ process.env.CLAWDBOT_GATEWAY_TOKEN = "token-123";
+ const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig;
+ await noteSecurityWarnings(cfg);
+ const message = lastMessage();
+ expect(message).toContain("WARNING");
+ expect(message).not.toContain("CRITICAL");
+ });
+
+ it("treats whitespace token as missing", async () => {
+ const cfg = {
+ gateway: { bind: "lan", auth: { mode: "token", token: " " } },
+ } as ClawdbotConfig;
+ await noteSecurityWarnings(cfg);
+ const message = lastMessage();
+ expect(message).toContain("CRITICAL");
+ });
+
+ it("skips warning for loopback bind", async () => {
+ const cfg = { gateway: { bind: "loopback" } } as ClawdbotConfig;
+ await noteSecurityWarnings(cfg);
+ const message = lastMessage();
+ expect(message).toContain("No channel security warnings detected");
+ expect(message).not.toContain("Gateway bound");
+ });
+});
diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts
index 483917faa..620a7fd7d 100644
--- a/src/commands/doctor-security.ts
+++ b/src/commands/doctor-security.ts
@@ -1,10 +1,12 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
-import type { ClawdbotConfig } from "../config/config.js";
+import type { ClawdbotConfig, GatewayBindMode } from "../config/config.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { note } from "../terminal/note.js";
import { formatCliCommand } from "../cli/command-format.js";
+import { resolveGatewayAuth } from "../gateway/auth.js";
+import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];
@@ -16,50 +18,55 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
// Check for dangerous gateway binding configurations
// that expose the gateway to network without proper auth
- const gatewayBind = cfg.gateway?.bind ?? "loopback";
+ const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string;
const customBindHost = cfg.gateway?.customBindHost?.trim();
- const authMode = cfg.gateway?.auth?.mode ?? "off";
- const authToken = cfg.gateway?.auth?.token;
- const authPassword = cfg.gateway?.auth?.password;
+ const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"];
+ const bindMode = bindModes.includes(gatewayBind as GatewayBindMode)
+ ? (gatewayBind as GatewayBindMode)
+ : undefined;
+ const resolvedBindHost = bindMode
+ ? await resolveGatewayBindHost(bindMode, customBindHost)
+ : "0.0.0.0";
+ const isExposed = !isLoopbackHost(resolvedBindHost);
- const isLoopbackBindHost = (host: string) => {
- const normalized = host.trim().toLowerCase();
- return (
- normalized === "localhost" ||
- normalized === "::1" ||
- normalized === "[::1]" ||
- normalized.startsWith("127.")
- );
- };
-
- // Bindings that expose gateway beyond localhost
- const exposedBindings = ["all", "lan", "0.0.0.0"];
- const isExposed =
- exposedBindings.includes(gatewayBind) ||
- (gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
+ const resolvedAuth = resolveGatewayAuth({
+ authConfig: cfg.gateway?.auth,
+ env: process.env,
+ tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
+ });
+ const authToken = resolvedAuth.token?.trim() ?? "";
+ const authPassword = resolvedAuth.password?.trim() ?? "";
+ const hasToken = authToken.length > 0;
+ const hasPassword = authPassword.length > 0;
+ const hasSharedSecret =
+ (resolvedAuth.mode === "token" && hasToken) ||
+ (resolvedAuth.mode === "password" && hasPassword);
+ const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`;
if (isExposed) {
- if (authMode === "off") {
+ if (!hasSharedSecret) {
+ const authFixLines =
+ resolvedAuth.mode === "password"
+ ? [
+ ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
+ ` Or switch to token: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
+ ]
+ : [
+ ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
+ ` Or set token directly: ${formatCliCommand(
+ "clawdbot config set gateway.auth.mode token",
+ )}`,
+ ];
warnings.push(
- `- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
+ `- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`,
` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
- ` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
- );
- } else if (authMode === "token" && !authToken) {
- warnings.push(
- `- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
- ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
- );
- } else if (authMode === "password" && !authPassword) {
- warnings.push(
- `- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
- ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
+ ...authFixLines,
);
} else {
// Auth is configured, but still warn about network exposure
warnings.push(
- `- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
+ `- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`,
` Ensure your auth credentials are strong and not exposed.`,
);
}
diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts
index b5cf45166..a33cc531f 100644
--- a/src/commands/onboard-non-interactive.gateway.test.ts
+++ b/src/commands/onboard-non-interactive.gateway.test.ts
@@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);
- it("auto-enables token auth when binding LAN and persists the token", async () => {
+ it("auto-generates token auth when binding LAN and persists the token", async () => {
if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return;
@@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
installDaemon: false,
gatewayPort: port,
gatewayBind: "lan",
- gatewayAuth: "off",
},
runtime,
);
diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts
index fedf1ad19..70772fa9f 100644
--- a/src/commands/onboard-non-interactive/local/gateway-config.ts
+++ b/src/commands/onboard-non-interactive/local/gateway-config.ts
@@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: {
const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort;
let bind = opts.gatewayBind ?? "loopback";
- let authMode = opts.gatewayAuth ?? "token";
+ const authModeRaw = opts.gatewayAuth ?? "token";
+ if (authModeRaw !== "token" && authModeRaw !== "password") {
+ runtime.error("Invalid --gateway-auth (use token|password).");
+ runtime.exit(1);
+ return null;
+ }
+ let authMode = authModeRaw;
const tailscaleMode = opts.tailscale ?? "off";
const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit);
// Tighten config to safe combos:
// - If Tailscale is on, force loopback bind (the tunnel handles external access).
- // - If binding beyond loopback, disallow auth=off.
// - If using Tailscale Funnel, require password auth.
if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback";
- if (authMode === "off" && bind !== "loopback") authMode = "token";
if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password";
let nextConfig = params.nextConfig;
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index e31611d30..351a9bfb6 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -33,7 +33,7 @@ export type AuthChoice =
| "copilot-proxy"
| "qwen-portal"
| "skip";
-export type GatewayAuthChoice = "off" | "token" | "password";
+export type GatewayAuthChoice = "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full";
export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet";
export type TailscaleMode = "off" | "serve" | "funnel";
diff --git a/src/config/schema.ts b/src/config/schema.ts
index ada88dde6..24d6bccfe 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -199,6 +199,7 @@ const FIELD_LABELS: Record = {
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
+ "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
@@ -321,6 +322,8 @@ const FIELD_LABELS: Record = {
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
"channels.discord.retry.jitter": "Discord Retry Jitter",
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
+ "channels.discord.intents.presence": "Discord Presence Intent",
+ "channels.discord.intents.guildMembers": "Discord Guild Members Intent",
"channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.allowBots": "Slack Allow Bot Messages",
"channels.discord.token": "Discord Bot Token",
@@ -379,6 +382,8 @@ const FIELD_HELP: Record = {
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
"gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
+ "gateway.controlUi.dangerouslyDisableDeviceAuth":
+ "DANGEROUS. Disable Control UI device identity checks (token/password only).",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
@@ -657,6 +662,10 @@ const FIELD_HELP: Record = {
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
"channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.",
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
+ "channels.discord.intents.presence":
+ "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
+ "channels.discord.intents.guildMembers":
+ "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
"channels.slack.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
};
diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts
index 071d6e6a7..70ea5f1fb 100644
--- a/src/config/types.discord.ts
+++ b/src/config/types.discord.ts
@@ -72,6 +72,13 @@ export type DiscordActionConfig = {
channels?: boolean;
};
+export type DiscordIntentsConfig = {
+ /** Enable Guild Presences privileged intent (requires Portal opt-in). Default: false. */
+ presence?: boolean;
+ /** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */
+ guildMembers?: boolean;
+};
+
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
@@ -139,6 +146,8 @@ export type DiscordAccountConfig = {
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding configuration. */
execApprovals?: DiscordExecApprovalConfig;
+ /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */
+ intents?: DiscordIntentsConfig;
};
export type DiscordConfig = {
diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts
index 4c7ddcdf3..d80b721ec 100644
--- a/src/config/types.gateway.ts
+++ b/src/config/types.gateway.ts
@@ -66,6 +66,8 @@ export type GatewayControlUiConfig = {
basePath?: string;
/** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean;
+ /** DANGEROUS: Disable device identity checks for the Control UI (default: false). */
+ dangerouslyDisableDeviceAuth?: boolean;
};
export type GatewayAuthMode = "token" | "password";
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 4b1b9338a..374e6e8aa 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -256,6 +256,13 @@ export const DiscordAccountSchema = z
})
.strict()
.optional(),
+ intents: z
+ .object({
+ presence: z.boolean().optional(),
+ guildMembers: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
})
.strict();
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 3c5bba8d7..f39b001fa 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -319,6 +319,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(),
basePath: z.string().optional(),
allowInsecureAuth: z.boolean().optional(),
+ dangerouslyDisableDeviceAuth: z.boolean().optional(),
})
.strict()
.optional(),
diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts
index a6c43087d..d5488cb98 100644
--- a/src/discord/monitor.slash.test.ts
+++ b/src/discord/monitor.slash.test.ts
@@ -16,6 +16,7 @@ vi.mock("@buape/carbon", () => ({
MessageCreateListener: class {},
MessageReactionAddListener: class {},
MessageReactionRemoveListener: class {},
+ PresenceUpdateListener: class {},
Row: class {
constructor(_components: unknown[]) {}
},
diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts
index 0eb5e2e8e..770ae6d6c 100644
--- a/src/discord/monitor/listeners.ts
+++ b/src/discord/monitor/listeners.ts
@@ -4,11 +4,13 @@ import {
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
+ PresenceUpdateListener,
} from "@buape/carbon";
import { danger } from "../../globals.js";
import { formatDurationSeconds } from "../../infra/format-duration.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
+import { setPresence } from "./presence-cache.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import {
@@ -269,3 +271,34 @@ async function handleDiscordReactionEvent(params: {
params.logger.error(danger(`discord reaction handler failed: ${String(err)}`));
}
}
+
+type PresenceUpdateEvent = Parameters[0];
+
+export class DiscordPresenceListener extends PresenceUpdateListener {
+ private logger?: Logger;
+ private accountId?: string;
+
+ constructor(params: { logger?: Logger; accountId?: string }) {
+ super();
+ this.logger = params.logger;
+ this.accountId = params.accountId;
+ }
+
+ async handle(data: PresenceUpdateEvent) {
+ try {
+ const userId =
+ "user" in data && data.user && typeof data.user === "object" && "id" in data.user
+ ? String(data.user.id)
+ : undefined;
+ if (!userId) return;
+ setPresence(
+ this.accountId,
+ userId,
+ data as import("discord-api-types/v10").GatewayPresenceUpdate,
+ );
+ } catch (err) {
+ const logger = this.logger ?? discordEventQueueLog;
+ logger.error(danger(`discord presence handler failed: ${String(err)}`));
+ }
+ }
+}
diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts
new file mode 100644
index 000000000..8cdf8cefa
--- /dev/null
+++ b/src/discord/monitor/presence-cache.test.ts
@@ -0,0 +1,39 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import type { GatewayPresenceUpdate } from "discord-api-types/v10";
+import {
+ clearPresences,
+ getPresence,
+ presenceCacheSize,
+ setPresence,
+} from "./presence-cache.js";
+
+describe("presence-cache", () => {
+ beforeEach(() => {
+ clearPresences();
+ });
+
+ it("scopes presence entries by account", () => {
+ const presenceA = { status: "online" } as GatewayPresenceUpdate;
+ const presenceB = { status: "idle" } as GatewayPresenceUpdate;
+
+ setPresence("account-a", "user-1", presenceA);
+ setPresence("account-b", "user-1", presenceB);
+
+ expect(getPresence("account-a", "user-1")).toBe(presenceA);
+ expect(getPresence("account-b", "user-1")).toBe(presenceB);
+ expect(getPresence("account-a", "user-2")).toBeUndefined();
+ });
+
+ it("clears presence per account", () => {
+ const presence = { status: "dnd" } as GatewayPresenceUpdate;
+
+ setPresence("account-a", "user-1", presence);
+ setPresence("account-b", "user-2", presence);
+
+ clearPresences("account-a");
+
+ expect(getPresence("account-a", "user-1")).toBeUndefined();
+ expect(getPresence("account-b", "user-2")).toBe(presence);
+ expect(presenceCacheSize()).toBe(1);
+ });
+});
diff --git a/src/discord/monitor/presence-cache.ts b/src/discord/monitor/presence-cache.ts
new file mode 100644
index 000000000..e112297e8
--- /dev/null
+++ b/src/discord/monitor/presence-cache.ts
@@ -0,0 +1,52 @@
+import type { GatewayPresenceUpdate } from "discord-api-types/v10";
+
+/**
+ * In-memory cache of Discord user presence data.
+ * Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled.
+ */
+const presenceCache = new Map>();
+
+function resolveAccountKey(accountId?: string): string {
+ return accountId ?? "default";
+}
+
+/** Update cached presence for a user. */
+export function setPresence(
+ accountId: string | undefined,
+ userId: string,
+ data: GatewayPresenceUpdate,
+): void {
+ const accountKey = resolveAccountKey(accountId);
+ let accountCache = presenceCache.get(accountKey);
+ if (!accountCache) {
+ accountCache = new Map();
+ presenceCache.set(accountKey, accountCache);
+ }
+ accountCache.set(userId, data);
+}
+
+/** Get cached presence for a user. Returns undefined if not cached. */
+export function getPresence(
+ accountId: string | undefined,
+ userId: string,
+): GatewayPresenceUpdate | undefined {
+ return presenceCache.get(resolveAccountKey(accountId))?.get(userId);
+}
+
+/** Clear cached presence data. */
+export function clearPresences(accountId?: string): void {
+ if (accountId) {
+ presenceCache.delete(resolveAccountKey(accountId));
+ return;
+ }
+ presenceCache.clear();
+}
+
+/** Get the number of cached presence entries. */
+export function presenceCacheSize(): number {
+ let total = 0;
+ for (const accountCache of presenceCache.values()) {
+ total += accountCache.size;
+ }
+ return total;
+}
diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts
index 0599d104e..ed5299cf7 100644
--- a/src/discord/monitor/provider.ts
+++ b/src/discord/monitor/provider.ts
@@ -28,6 +28,7 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import {
DiscordMessageListener,
+ DiscordPresenceListener,
DiscordReactionListener,
DiscordReactionRemoveListener,
registerDiscordListener,
@@ -109,6 +110,25 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
return details.length > 0 ? ` (${details.join(", ")})` : "";
}
+function resolveDiscordGatewayIntents(
+ intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig,
+): number {
+ let intents =
+ GatewayIntents.Guilds |
+ GatewayIntents.GuildMessages |
+ GatewayIntents.MessageContent |
+ GatewayIntents.DirectMessages |
+ GatewayIntents.GuildMessageReactions |
+ GatewayIntents.DirectMessageReactions;
+ if (intentsConfig?.presence) {
+ intents |= GatewayIntents.GuildPresences;
+ }
+ if (intentsConfig?.guildMembers) {
+ intents |= GatewayIntents.GuildMembers;
+ }
+ return intents;
+}
+
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
@@ -451,13 +471,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
reconnect: {
maxAttempts: Number.POSITIVE_INFINITY,
},
- intents:
- GatewayIntents.Guilds |
- GatewayIntents.GuildMessages |
- GatewayIntents.MessageContent |
- GatewayIntents.DirectMessages |
- GatewayIntents.GuildMessageReactions |
- GatewayIntents.DirectMessageReactions,
+ intents: resolveDiscordGatewayIntents(discordCfg.intents),
autoInteractions: true,
}),
],
@@ -527,6 +541,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}),
);
+ if (discordCfg.intents?.presence) {
+ registerDiscordListener(
+ client.listeners,
+ new DiscordPresenceListener({ logger, accountId: account.accountId }),
+ );
+ runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
+ }
+
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
// Start exec approvals handler after client is ready
diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts
index 90bd5c41e..7e1022124 100644
--- a/src/gateway/auth.test.ts
+++ b/src/gateway/auth.test.ts
@@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js";
describe("gateway auth", () => {
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: false },
- connectAuth: null,
+ auth: { mode: "token", token: "secret", allowTailscale: false },
+ connectAuth: { token: "secret" },
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
req: {} as never,
});
@@ -63,40 +63,10 @@ describe("gateway auth", () => {
expect(res.reason).toBe("password_missing_config");
});
- it("reports tailscale auth reasons when required", async () => {
- const reqBase = {
- socket: { remoteAddress: "100.100.100.100" },
- headers: { host: "gateway.local" },
- };
-
- const missingUser = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
- req: reqBase as never,
- });
- expect(missingUser.ok).toBe(false);
- expect(missingUser.reason).toBe("tailscale_user_missing");
-
- const missingProxy = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
- req: {
- ...reqBase,
- headers: {
- host: "gateway.local",
- "tailscale-user-login": "peter",
- "tailscale-user-name": "Peter",
- },
- } as never,
- });
- expect(missingProxy.ok).toBe(false);
- expect(missingProxy.reason).toBe("tailscale_proxy_missing");
- });
-
it("treats local tailscale serve hostnames as direct", async () => {
const res = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
+ auth: { mode: "token", token: "secret", allowTailscale: true },
+ connectAuth: { token: "secret" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { host: "gateway.tailnet-1234.ts.net:443" },
@@ -104,21 +74,7 @@ describe("gateway auth", () => {
});
expect(res.ok).toBe(true);
- expect(res.method).toBe("none");
- });
-
- it("does not treat tailscale clients as direct", async () => {
- const res = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
- req: {
- socket: { remoteAddress: "100.64.0.42" },
- headers: { host: "gateway.tailnet-1234.ts.net" },
- } as never,
- });
-
- expect(res.ok).toBe(false);
- expect(res.reason).toBe("tailscale_user_missing");
+ expect(res.method).toBe("token");
});
it("allows tailscale identity to satisfy token mode auth", async () => {
@@ -143,41 +99,4 @@ describe("gateway auth", () => {
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
});
-
- it("rejects mismatched tailscale identity when required", async () => {
- const res = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
- tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
- req: {
- socket: { remoteAddress: "127.0.0.1" },
- headers: {
- host: "gateway.local",
- "x-forwarded-for": "100.64.0.1",
- "x-forwarded-proto": "https",
- "x-forwarded-host": "ai-hub.bone-egret.ts.net",
- "tailscale-user-login": "peter@example.com",
- "tailscale-user-name": "Peter",
- },
- } as never,
- });
-
- expect(res.ok).toBe(false);
- expect(res.reason).toBe("tailscale_user_mismatch");
- });
-
- it("treats trusted proxy loopback clients as direct", async () => {
- const res = await authorizeGatewayConnect({
- auth: { mode: "none", allowTailscale: true },
- connectAuth: null,
- trustedProxies: ["10.0.0.2"],
- req: {
- socket: { remoteAddress: "10.0.0.2" },
- headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
- } as never,
- });
-
- expect(res.ok).toBe(true);
- expect(res.method).toBe("none");
- });
});
diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts
index f716be5dd..1adc367a2 100644
--- a/src/gateway/auth.ts
+++ b/src/gateway/auth.ts
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
-export type ResolvedGatewayAuthMode = "none" | "token" | "password";
+export type ResolvedGatewayAuthMode = "token" | "password";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
@@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = {
ok: boolean;
- method?: "none" | "token" | "password" | "tailscale" | "device-token";
+ method?: "token" | "password" | "tailscale" | "device-token";
user?: string;
reason?: string;
};
@@ -84,7 +84,7 @@ function resolveRequestClientIp(
});
}
-function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
+export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
if (!req) return false;
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
if (!isLoopbackAddress(clientIp)) return false;
@@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: {
user: tailscaleCheck.user.login,
};
}
- if (auth.mode === "none") {
- return { ok: false, reason: tailscaleCheck.reason };
- }
- }
-
- if (auth.mode === "none") {
- return { ok: true, method: "none" };
}
if (auth.mode === "token") {
diff --git a/src/gateway/gateway.e2e.test.ts b/src/gateway/gateway.e2e.test.ts
index 47ce694ce..0f65d16ac 100644
--- a/src/gateway/gateway.e2e.test.ts
+++ b/src/gateway/gateway.e2e.test.ts
@@ -181,7 +181,7 @@ describe("gateway e2e", () => {
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
- auth: { mode: "none" },
+ auth: { mode: "token", token: wizardToken },
controlUiEnabled: false,
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.intro("Wizard E2E");
@@ -197,6 +197,7 @@ describe("gateway e2e", () => {
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
+ token: wizardToken,
clientDisplayName: "vitest-wizard",
});
diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts
index 6474f285b..2eb3dcef9 100644
--- a/src/gateway/server.auth.e2e.test.ts
+++ b/src/gateway/server.auth.e2e.test.ts
@@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => {
await new Promise((resolve) => ws.once("close", () => resolve()));
});
+ test("requires nonce when host is non-local", async () => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
+ headers: { host: "example.com" },
+ });
+ await new Promise((resolve) => ws.once("open", resolve));
+
+ const res = await connectReq(ws);
+ expect(res.ok).toBe(false);
+ expect(res.error?.message).toBe("device nonce required");
+ await new Promise((resolve) => ws.once("close", () => resolve()));
+ });
+
test(
"invalid connect params surface in response and close reason",
{ timeout: 60_000 },
@@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => {
test("allows control ui with device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
+ testState.gatewayAuth = { mode: "token", token: "secret" };
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: {
@@ -352,19 +365,45 @@ describe("gateway server auth/connect", () => {
}
});
- test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
- testState.gatewayAuth = { mode: "none" };
+ test("allows control ui with stale device identity when device auth is disabled", async () => {
+ testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
+ testState.gatewayAuth = { mode: "token", token: "secret" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
- delete process.env.CLAWDBOT_GATEWAY_TOKEN;
+ process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
const server = await startGatewayServer(port);
- const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
- headers: { "x-forwarded-for": "203.0.113.10" },
+ const ws = await openWs(port);
+ const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
+ await import("../infra/device-identity.js");
+ const identity = loadOrCreateDeviceIdentity();
+ const signedAtMs = Date.now() - 60 * 60 * 1000;
+ const payload = buildDeviceAuthPayload({
+ deviceId: identity.deviceId,
+ clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
+ clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
+ role: "operator",
+ scopes: [],
+ signedAtMs,
+ token: "secret",
});
- await new Promise((resolve) => ws.once("open", resolve));
- const res = await connectReq(ws, { skipDefaultAuth: true });
- expect(res.ok).toBe(false);
- expect(res.error?.message ?? "").toContain("gateway auth required");
+ const device = {
+ id: identity.deviceId,
+ publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
+ signature: signDevicePayload(identity.privateKeyPem, payload),
+ signedAt: signedAtMs,
+ };
+ const res = await connectReq(ws, {
+ token: "secret",
+ device,
+ client: {
+ id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
+ version: "1.0.0",
+ platform: "web",
+ mode: GATEWAY_CLIENT_MODES.WEBCHAT,
+ },
+ });
+ expect(res.ok).toBe(true);
+ expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined();
ws.close();
await server.close();
if (prevToken === undefined) {
diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts
index 7f8f9f2c6..d1f6ae511 100644
--- a/src/gateway/server/ws-connection/message-handler.ts
+++ b/src/gateway/server/ws-connection/message-handler.ts
@@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
-import { authorizeGatewayConnect } from "../../auth.js";
+import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
-import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
+import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
+function resolveHostName(hostHeader?: string): string {
+ const host = (hostHeader ?? "").trim().toLowerCase();
+ if (!host) return "";
+ if (host.startsWith("[")) {
+ const end = host.indexOf("]");
+ if (end !== -1) return host.slice(1, end);
+ }
+ const [name] = host.split(":");
+ return name ?? "";
+}
+
type AuthProvidedKind = "token" | "password" | "none";
function formatGatewayAuthFailureMessage(params: {
@@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: {
const hasProxyHeaders = Boolean(forwardedFor || realIp);
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
- const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
- const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
+ const hostName = resolveHostName(requestHost);
+ const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
+ const hostIsTailscaleServe = hostName.endsWith(".ts.net");
+ const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
+ const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
+ const reportedClientIp =
+ isLocalClient || hasUntrustedProxyHeaders
+ ? undefined
+ : clientIp && !isLoopbackAddress(clientIp)
+ ? clientIp
+ : undefined;
if (hasUntrustedProxyHeaders) {
logWsControl.warn(
@@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: {
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
);
}
+ if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) {
+ logWsControl.warn(
+ "Loopback connection with non-local Host header. " +
+ "Treating it as remote. If you're behind a reverse proxy, " +
+ "set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.",
+ );
+ }
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
@@ -335,7 +362,7 @@ export function attachGatewayWsMessageHandler(params: {
connectParams.role = role;
connectParams.scopes = scopes;
- const device = connectParams.device;
+ const deviceRaw = connectParams.device;
let devicePublicKey: string | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
@@ -343,36 +370,14 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const allowInsecureControlUi =
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
- if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
- setHandshakeState("failed");
- setCloseCause("proxy-auth-required", {
- client: connectParams.client.id,
- clientDisplayName: connectParams.client.displayName,
- mode: connectParams.client.mode,
- version: connectParams.client.version,
- });
- send({
- type: "res",
- id: frame.id,
- ok: false,
- error: errorShape(
- ErrorCodes.INVALID_REQUEST,
- "gateway auth required behind reverse proxy",
- {
- details: {
- hint: "set gateway.auth or configure gateway.trustedProxies",
- },
- },
- ),
- });
- close(1008, "gateway auth required");
- return;
- }
-
+ const disableControlUiDeviceAuth =
+ isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
+ const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
+ const device = disableControlUiDeviceAuth ? null : deviceRaw;
if (!device) {
- const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
+ const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
- if (isControlUi && !allowInsecureControlUi) {
+ if (isControlUi && !allowControlUiBypass) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
setHandshakeState("failed");
setCloseCause("control-ui-insecure-auth", {
@@ -566,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: {
trustedProxies,
});
let authOk = authResult.ok;
- let authMethod = authResult.method ?? "none";
+ let authMethod =
+ authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
@@ -615,7 +621,7 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
- const skipPairing = allowInsecureControlUi && hasSharedAuth;
+ const skipPairing = allowControlUiBypass && hasSharedAuth;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({
@@ -736,9 +742,7 @@ export function attachGatewayWsMessageHandler(params: {
const shouldTrackPresence = !isGatewayCliClient(connectParams.client);
const clientId = connectParams.client.id;
const instanceId = connectParams.client.instanceId;
- const presenceKey = shouldTrackPresence
- ? (connectParams.device?.id ?? instanceId ?? connId)
- : undefined;
+ const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined;
logWs("in", "connect", {
connId,
@@ -766,10 +770,10 @@ export function attachGatewayWsMessageHandler(params: {
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode,
- deviceId: connectParams.device?.id,
+ deviceId: device?.id,
roles: [role],
scopes,
- instanceId: connectParams.device?.id ?? instanceId,
+ instanceId: device?.id ?? instanceId,
reason: "connect",
});
incrementPresenceVersion();
diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts
index 254365564..34c22c573 100644
--- a/src/gateway/test-helpers.server.ts
+++ b/src/gateway/test-helpers.server.ts
@@ -260,6 +260,9 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
let port = await getFreePort();
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
+ if (typeof token === "string") {
+ testState.gatewayAuth = { mode: "token", token };
+ }
const fallbackToken =
token ??
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts
new file mode 100644
index 000000000..42bc54b66
--- /dev/null
+++ b/src/infra/net/ssrf.pinning.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js";
+
+describe("ssrf pinning", () => {
+ it("pins resolved addresses for the target hostname", async () => {
+ const lookup = vi.fn(async () => [
+ { address: "93.184.216.34", family: 4 },
+ { address: "93.184.216.35", family: 4 },
+ ]);
+
+ const pinned = await resolvePinnedHostname("Example.com.", lookup);
+ expect(pinned.hostname).toBe("example.com");
+ expect(pinned.addresses).toEqual(["93.184.216.34", "93.184.216.35"]);
+
+ const first = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
+ pinned.lookup("example.com", (err, address, family) => {
+ if (err) reject(err);
+ else resolve({ address: address as string, family });
+ });
+ });
+ expect(first.address).toBe("93.184.216.34");
+ expect(first.family).toBe(4);
+
+ const all = await new Promise((resolve, reject) => {
+ pinned.lookup("example.com", { all: true }, (err, addresses) => {
+ if (err) reject(err);
+ else resolve(addresses);
+ });
+ });
+ expect(Array.isArray(all)).toBe(true);
+ expect((all as Array<{ address: string }>).map((entry) => entry.address)).toEqual(
+ pinned.addresses,
+ );
+ });
+
+ it("rejects private DNS results", async () => {
+ const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]);
+ await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i);
+ });
+
+ it("falls back for non-matching hostnames", async () => {
+ const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => {
+ const cb = typeof options === "function" ? options : (callback as () => void);
+ (cb as (err: null, address: string, family: number) => void)(null, "1.2.3.4", 4);
+ });
+ const lookup = createPinnedLookup({
+ hostname: "example.com",
+ addresses: ["93.184.216.34"],
+ fallback,
+ });
+
+ const result = await new Promise<{ address: string }>((resolve, reject) => {
+ lookup("other.test", (err, address) => {
+ if (err) reject(err);
+ else resolve({ address: address as string });
+ });
+ });
+
+ expect(fallback).toHaveBeenCalledTimes(1);
+ expect(result.address).toBe("1.2.3.4");
+ });
+});
diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts
index 9b09cc4b1..297df0f03 100644
--- a/src/infra/net/ssrf.ts
+++ b/src/infra/net/ssrf.ts
@@ -1,4 +1,12 @@
import { lookup as dnsLookup } from "node:dns/promises";
+import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
+import { Agent, type Dispatcher } from "undici";
+
+type LookupCallback = (
+ err: NodeJS.ErrnoException | null,
+ address: string | LookupAddress[],
+ family?: number,
+) => void;
export class SsrFBlockedError extends Error {
constructor(message: string) {
@@ -101,10 +109,71 @@ export function isBlockedHostname(hostname: string): boolean {
);
}
-export async function assertPublicHostname(
+export function createPinnedLookup(params: {
+ hostname: string;
+ addresses: string[];
+ fallback?: typeof dnsLookupCb;
+}): typeof dnsLookupCb {
+ const normalizedHost = normalizeHostname(params.hostname);
+ const fallback = params.fallback ?? dnsLookupCb;
+ const fallbackLookup = fallback as unknown as (
+ hostname: string,
+ callback: LookupCallback,
+ ) => void;
+ const fallbackWithOptions = fallback as unknown as (
+ hostname: string,
+ options: unknown,
+ callback: LookupCallback,
+ ) => void;
+ const records = params.addresses.map((address) => ({
+ address,
+ family: address.includes(":") ? 6 : 4,
+ }));
+ let index = 0;
+
+ return ((host: string, options?: unknown, callback?: unknown) => {
+ const cb: LookupCallback =
+ typeof options === "function" ? (options as LookupCallback) : (callback as LookupCallback);
+ if (!cb) return;
+ const normalized = normalizeHostname(host);
+ if (!normalized || normalized !== normalizedHost) {
+ if (typeof options === "function" || options === undefined) {
+ return fallbackLookup(host, cb);
+ }
+ return fallbackWithOptions(host, options, cb);
+ }
+
+ const opts =
+ typeof options === "object" && options !== null
+ ? (options as { all?: boolean; family?: number })
+ : {};
+ const requestedFamily =
+ typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
+ const candidates =
+ requestedFamily === 4 || requestedFamily === 6
+ ? records.filter((entry) => entry.family === requestedFamily)
+ : records;
+ const usable = candidates.length > 0 ? candidates : records;
+ if (opts.all) {
+ cb(null, usable as LookupAddress[]);
+ return;
+ }
+ const chosen = usable[index % usable.length];
+ index += 1;
+ cb(null, chosen.address, chosen.family);
+ }) as typeof dnsLookupCb;
+}
+
+export type PinnedHostname = {
+ hostname: string;
+ addresses: string[];
+ lookup: typeof dnsLookupCb;
+};
+
+export async function resolvePinnedHostname(
hostname: string,
lookupFn: LookupFn = dnsLookup,
-): Promise {
+): Promise {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
@@ -128,4 +197,46 @@ export async function assertPublicHostname(
throw new SsrFBlockedError("Blocked: resolves to private/internal IP address");
}
}
+
+ const addresses = Array.from(new Set(results.map((entry) => entry.address)));
+ if (addresses.length === 0) {
+ throw new Error(`Unable to resolve hostname: ${hostname}`);
+ }
+
+ return {
+ hostname: normalized,
+ addresses,
+ lookup: createPinnedLookup({ hostname: normalized, addresses }),
+ };
+}
+
+export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher {
+ return new Agent({
+ connect: {
+ lookup: pinned.lookup,
+ },
+ });
+}
+
+export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise {
+ if (!dispatcher) return;
+ const candidate = dispatcher as { close?: () => Promise | void; destroy?: () => void };
+ try {
+ if (typeof candidate.close === "function") {
+ await candidate.close();
+ return;
+ }
+ if (typeof candidate.destroy === "function") {
+ candidate.destroy();
+ }
+ } catch {
+ // ignore dispatcher cleanup errors
+ }
+}
+
+export async function assertPublicHostname(
+ hostname: string,
+ lookupFn: LookupFn = dnsLookup,
+): Promise {
+ await resolvePinnedHostname(hostname, lookupFn);
}
diff --git a/src/media/input-files.ts b/src/media/input-files.ts
index 8b1d1945a..b337e17c5 100644
--- a/src/media/input-files.ts
+++ b/src/media/input-files.ts
@@ -1,5 +1,10 @@
import { logWarn } from "../logger.js";
-import { assertPublicHostname } from "../infra/net/ssrf.js";
+import {
+ closeDispatcher,
+ createPinnedDispatcher,
+ resolvePinnedHostname,
+} from "../infra/net/ssrf.js";
+import type { Dispatcher } from "undici";
type CanvasModule = typeof import("@napi-rs/canvas");
type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -154,50 +159,57 @@ export async function fetchWithGuard(params: {
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`);
}
- await assertPublicHostname(parsedUrl.hostname);
+ const pinned = await resolvePinnedHostname(parsedUrl.hostname);
+ const dispatcher = createPinnedDispatcher(pinned);
- const response = await fetch(parsedUrl, {
- signal: controller.signal,
- headers: { "User-Agent": "Clawdbot-Gateway/1.0" },
- redirect: "manual",
- });
+ try {
+ const response = await fetch(parsedUrl, {
+ signal: controller.signal,
+ headers: { "User-Agent": "Clawdbot-Gateway/1.0" },
+ redirect: "manual",
+ dispatcher,
+ } as RequestInit & { dispatcher: Dispatcher });
- if (isRedirectStatus(response.status)) {
- const location = response.headers.get("location");
- if (!location) {
- throw new Error(`Redirect missing location header (${response.status})`);
+ if (isRedirectStatus(response.status)) {
+ const location = response.headers.get("location");
+ if (!location) {
+ throw new Error(`Redirect missing location header (${response.status})`);
+ }
+ redirectCount += 1;
+ if (redirectCount > params.maxRedirects) {
+ throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
+ }
+ void response.body?.cancel();
+ currentUrl = new URL(location, parsedUrl).toString();
+ continue;
}
- redirectCount += 1;
- if (redirectCount > params.maxRedirects) {
- throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
- currentUrl = new URL(location, parsedUrl).toString();
- continue;
- }
- if (!response.ok) {
- throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
- }
-
- const contentLength = response.headers.get("content-length");
- if (contentLength) {
- const size = parseInt(contentLength, 10);
- if (size > params.maxBytes) {
- throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`);
+ const contentLength = response.headers.get("content-length");
+ if (contentLength) {
+ const size = parseInt(contentLength, 10);
+ if (size > params.maxBytes) {
+ throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`);
+ }
}
- }
- const buffer = Buffer.from(await response.arrayBuffer());
- if (buffer.byteLength > params.maxBytes) {
- throw new Error(
- `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`,
- );
- }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (buffer.byteLength > params.maxBytes) {
+ throw new Error(
+ `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`,
+ );
+ }
- const contentType = response.headers.get("content-type") || undefined;
- const parsed = parseContentType(contentType);
- const mimeType = parsed.mimeType ?? "application/octet-stream";
- return { buffer, mimeType, contentType };
+ const contentType = response.headers.get("content-type") || undefined;
+ const parsed = parseContentType(contentType);
+ const mimeType = parsed.mimeType ?? "application/octet-stream";
+ return { buffer, mimeType, contentType };
+ } finally {
+ await closeDispatcher(dispatcher);
+ }
}
} finally {
clearTimeout(timeoutId);
diff --git a/src/media/store.redirect.test.ts b/src/media/store.redirect.test.ts
index 474f9c050..90dacba9a 100644
--- a/src/media/store.redirect.test.ts
+++ b/src/media/store.redirect.test.ts
@@ -18,6 +18,9 @@ vi.doMock("node:os", () => ({
vi.doMock("node:https", () => ({
request: (...args: unknown[]) => mockRequest(...args),
}));
+vi.doMock("node:dns/promises", () => ({
+ lookup: async () => [{ address: "93.184.216.34", family: 4 }],
+}));
const loadStore = async () => await import("./store.js");
diff --git a/src/media/store.ts b/src/media/store.ts
index cd6c92411..c24614016 100644
--- a/src/media/store.ts
+++ b/src/media/store.ts
@@ -1,10 +1,12 @@
import crypto from "node:crypto";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
-import { request } from "node:https";
+import { request as httpRequest } from "node:http";
+import { request as httpsRequest } from "node:https";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { resolveConfigDir } from "../utils.js";
+import { resolvePinnedHostname } from "../infra/net/ssrf.js";
import { detectMime, extensionForMime } from "./mime.js";
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
@@ -88,51 +90,67 @@ async function downloadToFile(
maxRedirects = 5,
): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> {
return await new Promise((resolve, reject) => {
- const req = request(url, { headers }, (res) => {
- // Follow redirects
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
- const location = res.headers.location;
- if (!location || maxRedirects <= 0) {
- reject(new Error(`Redirect loop or missing Location header`));
- return;
- }
- const redirectUrl = new URL(location, url).href;
- resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
- return;
- }
- if (!res.statusCode || res.statusCode >= 400) {
- reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
- return;
- }
- let total = 0;
- const sniffChunks: Buffer[] = [];
- let sniffLen = 0;
- const out = createWriteStream(dest);
- res.on("data", (chunk) => {
- total += chunk.length;
- if (sniffLen < 16384) {
- sniffChunks.push(chunk);
- sniffLen += chunk.length;
- }
- if (total > MAX_BYTES) {
- req.destroy(new Error("Media exceeds 5MB limit"));
- }
- });
- pipeline(res, out)
- .then(() => {
- const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384));
- const rawHeader = res.headers["content-type"];
- const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
- resolve({
- headerMime,
- sniffBuffer,
- size: total,
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(url);
+ } catch {
+ reject(new Error("Invalid URL"));
+ return;
+ }
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
+ reject(new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`));
+ return;
+ }
+ const requestImpl = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest;
+ resolvePinnedHostname(parsedUrl.hostname)
+ .then((pinned) => {
+ const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => {
+ // Follow redirects
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
+ const location = res.headers.location;
+ if (!location || maxRedirects <= 0) {
+ reject(new Error(`Redirect loop or missing Location header`));
+ return;
+ }
+ const redirectUrl = new URL(location, url).href;
+ resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1));
+ return;
+ }
+ if (!res.statusCode || res.statusCode >= 400) {
+ reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
+ return;
+ }
+ let total = 0;
+ const sniffChunks: Buffer[] = [];
+ let sniffLen = 0;
+ const out = createWriteStream(dest);
+ res.on("data", (chunk) => {
+ total += chunk.length;
+ if (sniffLen < 16384) {
+ sniffChunks.push(chunk);
+ sniffLen += chunk.length;
+ }
+ if (total > MAX_BYTES) {
+ req.destroy(new Error("Media exceeds 5MB limit"));
+ }
});
- })
- .catch(reject);
- });
- req.on("error", reject);
- req.end();
+ pipeline(res, out)
+ .then(() => {
+ const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384));
+ const rawHeader = res.headers["content-type"];
+ const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
+ resolve({
+ headerMime,
+ sniffBuffer,
+ size: total,
+ });
+ })
+ .catch(reject);
+ });
+ req.on("error", reject);
+ req.end();
+ })
+ .catch(reject);
});
}
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 60782ff6d..c0c201ff0 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -63,6 +63,11 @@ export type {
ClawdbotPluginService,
ClawdbotPluginServiceContext,
} from "../plugins/types.js";
+export type {
+ GatewayRequestHandler,
+ GatewayRequestHandlerOptions,
+ RespondFn,
+} from "../gateway/server-methods/types.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";
export { normalizePluginHttpPath } from "../plugins/http-path.js";
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts
index 6dce5c896..9aabb9721 100644
--- a/src/security/audit-extra.ts
+++ b/src/security/audit-extra.ts
@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
- formatOctal,
- isGroupReadable,
- isGroupWritable,
- isWorldReadable,
- isWorldWritable,
- modeBits,
+ formatPermissionDetail,
+ formatPermissionRemediation,
+ inspectPathPermissions,
safeStat,
} from "./audit-fs.js";
+import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditFinding = {
checkId: string;
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
export async function collectIncludeFilePermFindings(params: {
configSnapshot: ConfigFileSnapshot;
+ env?: NodeJS.ProcessEnv;
+ platform?: NodeJS.Platform;
+ execIcacls?: ExecFn;
}): Promise {
const findings: SecurityAuditFinding[] = [];
if (!params.configSnapshot.exists) return findings;
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop
- const st = await safeStat(p);
- if (!st.ok) continue;
- const bits = modeBits(st.mode);
- if (isWorldWritable(bits) || isGroupWritable(bits)) {
+ const perms = await inspectPathPermissions(p, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (!perms.ok) continue;
+ if (perms.worldWritable || perms.groupWritable) {
findings.push({
checkId: "fs.config_include.perms_writable",
severity: "critical",
title: "Config include file is writable by others",
- detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
- remediation: `chmod 600 ${p}`,
+ detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
+ remediation: formatPermissionRemediation({
+ targetPath: p,
+ perms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
- } else if (isWorldReadable(bits)) {
+ } else if (perms.worldReadable) {
findings.push({
checkId: "fs.config_include.perms_world_readable",
severity: "critical",
title: "Config include file is world-readable",
- detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
- remediation: `chmod 600 ${p}`,
+ detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
+ remediation: formatPermissionRemediation({
+ targetPath: p,
+ perms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
- } else if (isGroupReadable(bits)) {
+ } else if (perms.groupReadable) {
findings.push({
checkId: "fs.config_include.perms_group_readable",
severity: "warn",
title: "Config include file is group-readable",
- detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
- remediation: `chmod 600 ${p}`,
+ detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
+ remediation: formatPermissionRemediation({
+ targetPath: p,
+ perms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
}
}
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
cfg: ClawdbotConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
+ platform?: NodeJS.Platform;
+ execIcacls?: ExecFn;
}): Promise {
const findings: SecurityAuditFinding[] = [];
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
- const oauthStat = await safeStat(oauthDir);
- if (oauthStat.ok && oauthStat.isDir) {
- const bits = modeBits(oauthStat.mode);
- if (isWorldWritable(bits) || isGroupWritable(bits)) {
+ const oauthPerms = await inspectPathPermissions(oauthDir, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (oauthPerms.ok && oauthPerms.isDir) {
+ if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
findings.push({
checkId: "fs.credentials_dir.perms_writable",
severity: "critical",
title: "Credentials dir is writable by others",
- detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
- remediation: `chmod 700 ${oauthDir}`,
+ detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
+ remediation: formatPermissionRemediation({
+ targetPath: oauthDir,
+ perms: oauthPerms,
+ isDir: true,
+ posixMode: 0o700,
+ env: params.env,
+ }),
});
- } else if (isGroupReadable(bits) || isWorldReadable(bits)) {
+ } else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
findings.push({
checkId: "fs.credentials_dir.perms_readable",
severity: "warn",
title: "Credentials dir is readable by others",
- detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
- remediation: `chmod 700 ${oauthDir}`,
+ detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
+ remediation: formatPermissionRemediation({
+ targetPath: oauthDir,
+ perms: oauthPerms,
+ isDir: true,
+ posixMode: 0o700,
+ env: params.env,
+ }),
});
}
}
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop
- const authStat = await safeStat(authPath);
- if (authStat.ok) {
- const bits = modeBits(authStat.mode);
- if (isWorldWritable(bits) || isGroupWritable(bits)) {
+ const authPerms = await inspectPathPermissions(authPath, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (authPerms.ok) {
+ if (authPerms.worldWritable || authPerms.groupWritable) {
findings.push({
checkId: "fs.auth_profiles.perms_writable",
severity: "critical",
title: "auth-profiles.json is writable by others",
- detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
- remediation: `chmod 600 ${authPath}`,
+ detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
+ remediation: formatPermissionRemediation({
+ targetPath: authPath,
+ perms: authPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
- } else if (isWorldReadable(bits) || isGroupReadable(bits)) {
+ } else if (authPerms.worldReadable || authPerms.groupReadable) {
findings.push({
checkId: "fs.auth_profiles.perms_readable",
severity: "warn",
title: "auth-profiles.json is readable by others",
- detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
- remediation: `chmod 600 ${authPath}`,
+ detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
+ remediation: formatPermissionRemediation({
+ targetPath: authPath,
+ perms: authPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
}
}
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
// eslint-disable-next-line no-await-in-loop
- const storeStat = await safeStat(storePath);
- if (storeStat.ok) {
- const bits = modeBits(storeStat.mode);
- if (isWorldReadable(bits) || isGroupReadable(bits)) {
+ const storePerms = await inspectPathPermissions(storePath, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (storePerms.ok) {
+ if (storePerms.worldReadable || storePerms.groupReadable) {
findings.push({
checkId: "fs.sessions_store.perms_readable",
severity: "warn",
title: "sessions.json is readable by others",
- detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
- remediation: `chmod 600 ${storePath}`,
+ detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
+ remediation: formatPermissionRemediation({
+ targetPath: storePath,
+ perms: storePerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
}
}
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
if (expanded) {
const logPath = path.resolve(expanded);
- const st = await safeStat(logPath);
- if (st.ok) {
- const bits = modeBits(st.mode);
- if (isWorldReadable(bits) || isGroupReadable(bits)) {
+ const logPerms = await inspectPathPermissions(logPath, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (logPerms.ok) {
+ if (logPerms.worldReadable || logPerms.groupReadable) {
findings.push({
checkId: "fs.log_file.perms_readable",
severity: "warn",
title: "Log file is readable by others",
- detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
- remediation: `chmod 600 ${logPath}`,
+ detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
+ remediation: formatPermissionRemediation({
+ targetPath: logPath,
+ perms: logPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
}
}
diff --git a/src/security/audit-fs.ts b/src/security/audit-fs.ts
index 5832b64f8..6bf0aec26 100644
--- a/src/security/audit-fs.ts
+++ b/src/security/audit-fs.ts
@@ -1,5 +1,33 @@
import fs from "node:fs/promises";
+import {
+ formatIcaclsResetCommand,
+ formatWindowsAclSummary,
+ inspectWindowsAcl,
+ type ExecFn,
+} from "./windows-acl.js";
+
+export type PermissionCheck = {
+ ok: boolean;
+ isSymlink: boolean;
+ isDir: boolean;
+ mode: number | null;
+ bits: number | null;
+ source: "posix" | "windows-acl" | "unknown";
+ worldWritable: boolean;
+ groupWritable: boolean;
+ worldReadable: boolean;
+ groupReadable: boolean;
+ aclSummary?: string;
+ error?: string;
+};
+
+export type PermissionCheckOptions = {
+ platform?: NodeJS.Platform;
+ env?: NodeJS.ProcessEnv;
+ exec?: ExecFn;
+};
+
export async function safeStat(targetPath: string): Promise<{
ok: boolean;
isSymlink: boolean;
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
}
}
+export async function inspectPathPermissions(
+ targetPath: string,
+ opts?: PermissionCheckOptions,
+): Promise {
+ const st = await safeStat(targetPath);
+ if (!st.ok) {
+ return {
+ ok: false,
+ isSymlink: false,
+ isDir: false,
+ mode: null,
+ bits: null,
+ source: "unknown",
+ worldWritable: false,
+ groupWritable: false,
+ worldReadable: false,
+ groupReadable: false,
+ error: st.error,
+ };
+ }
+
+ const bits = modeBits(st.mode);
+ const platform = opts?.platform ?? process.platform;
+
+ if (platform === "win32") {
+ const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
+ if (!acl.ok) {
+ return {
+ ok: true,
+ isSymlink: st.isSymlink,
+ isDir: st.isDir,
+ mode: st.mode,
+ bits,
+ source: "unknown",
+ worldWritable: false,
+ groupWritable: false,
+ worldReadable: false,
+ groupReadable: false,
+ error: acl.error,
+ };
+ }
+ return {
+ ok: true,
+ isSymlink: st.isSymlink,
+ isDir: st.isDir,
+ mode: st.mode,
+ bits,
+ source: "windows-acl",
+ worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
+ groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
+ worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
+ groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
+ aclSummary: formatWindowsAclSummary(acl),
+ };
+ }
+
+ return {
+ ok: true,
+ isSymlink: st.isSymlink,
+ isDir: st.isDir,
+ mode: st.mode,
+ bits,
+ source: "posix",
+ worldWritable: isWorldWritable(bits),
+ groupWritable: isGroupWritable(bits),
+ worldReadable: isWorldReadable(bits),
+ groupReadable: isGroupReadable(bits),
+ };
+}
+
+export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
+ if (perms.source === "windows-acl") {
+ const summary = perms.aclSummary ?? "unknown";
+ return `${targetPath} acl=${summary}`;
+ }
+ return `${targetPath} mode=${formatOctal(perms.bits)}`;
+}
+
+export function formatPermissionRemediation(params: {
+ targetPath: string;
+ perms: PermissionCheck;
+ isDir: boolean;
+ posixMode: number;
+ env?: NodeJS.ProcessEnv;
+}): string {
+ if (params.perms.source === "windows-acl") {
+ return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
+ }
+ const mode = params.posixMode.toString(8).padStart(3, "0");
+ return `chmod ${mode} ${params.targetPath}`;
+}
+
export function modeBits(mode: number | null): number | null {
if (mode == null) return null;
return mode & 0o777;
diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts
index 2ee7e27ee..e87a6b47c 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -82,7 +82,7 @@ describe("security audit", () => {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
- auth: { mode: "none" as any },
+ auth: {},
},
};
@@ -120,6 +120,83 @@ describe("security audit", () => {
);
});
+ it("treats Windows ACL-only perms as secure", async () => {
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-"));
+ const stateDir = path.join(tmp, "state");
+ await fs.mkdir(stateDir, { recursive: true });
+ const configPath = path.join(stateDir, "clawdbot.json");
+ await fs.writeFile(configPath, "{}\n", "utf-8");
+
+ const user = "DESKTOP-TEST\\Tester";
+ const execIcacls = async (_cmd: string, args: string[]) => ({
+ stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
+ stderr: "",
+ });
+
+ const res = await runSecurityAudit({
+ config: {},
+ includeFilesystem: true,
+ includeChannelSecurity: false,
+ stateDir,
+ configPath,
+ platform: "win32",
+ env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
+ execIcacls,
+ });
+
+ const forbidden = new Set([
+ "fs.state_dir.perms_world_writable",
+ "fs.state_dir.perms_group_writable",
+ "fs.state_dir.perms_readable",
+ "fs.config.perms_writable",
+ "fs.config.perms_world_readable",
+ "fs.config.perms_group_readable",
+ ]);
+ for (const id of forbidden) {
+ expect(res.findings.some((f) => f.checkId === id)).toBe(false);
+ }
+ });
+
+ it("flags Windows ACLs when Users can read the state dir", async () => {
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-"));
+ const stateDir = path.join(tmp, "state");
+ await fs.mkdir(stateDir, { recursive: true });
+ const configPath = path.join(stateDir, "clawdbot.json");
+ await fs.writeFile(configPath, "{}\n", "utf-8");
+
+ const user = "DESKTOP-TEST\\Tester";
+ const execIcacls = async (_cmd: string, args: string[]) => {
+ const target = args[0];
+ if (target === stateDir) {
+ return {
+ stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`,
+ stderr: "",
+ };
+ }
+ return {
+ stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
+ stderr: "",
+ };
+ };
+
+ const res = await runSecurityAudit({
+ config: {},
+ includeFilesystem: true,
+ includeChannelSecurity: false,
+ stateDir,
+ configPath,
+ platform: "win32",
+ env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
+ execIcacls,
+ });
+
+ expect(
+ res.findings.some(
+ (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn",
+ ),
+ ).toBe(true);
+ });
+
it("warns when small models are paired with web/browser tools", async () => {
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },
@@ -293,7 +370,30 @@ describe("security audit", () => {
expect.arrayContaining([
expect.objectContaining({
checkId: "gateway.control_ui.insecure_auth",
- severity: "warn",
+ severity: "critical",
+ }),
+ ]),
+ );
+ });
+
+ it("warns when control UI device auth is disabled", async () => {
+ const cfg: ClawdbotConfig = {
+ gateway: {
+ controlUi: { dangerouslyDisableDeviceAuth: true },
+ },
+ };
+
+ const res = await runSecurityAudit({
+ config: cfg,
+ includeFilesystem: false,
+ includeChannelSecurity: false,
+ });
+
+ expect(res.findings).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ checkId: "gateway.control_ui.device_auth_disabled",
+ severity: "critical",
}),
]),
);
diff --git a/src/security/audit.ts b/src/security/audit.ts
index b2f9691c7..2169f197d 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -24,14 +24,11 @@ import {
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import {
- formatOctal,
- isGroupReadable,
- isGroupWritable,
- isWorldReadable,
- isWorldWritable,
- modeBits,
- safeStat,
+ formatPermissionDetail,
+ formatPermissionRemediation,
+ inspectPathPermissions,
} from "./audit-fs.js";
+import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditSeverity = "info" | "warn" | "critical";
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
export type SecurityAuditOptions = {
config: ClawdbotConfig;
+ env?: NodeJS.ProcessEnv;
+ platform?: NodeJS.Platform;
deep?: boolean;
includeFilesystem?: boolean;
includeChannelSecurity?: boolean;
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
plugins?: ReturnType;
/** Dependency injection for tests. */
probeGatewayFn?: typeof probeGateway;
+ /** Dependency injection for tests (Windows ACL checks). */
+ execIcacls?: ExecFn;
};
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
async function collectFilesystemFindings(params: {
stateDir: string;
configPath: string;
+ env?: NodeJS.ProcessEnv;
+ platform?: NodeJS.Platform;
+ execIcacls?: ExecFn;
}): Promise {
const findings: SecurityAuditFinding[] = [];
- const stateDirStat = await safeStat(params.stateDir);
- if (stateDirStat.ok) {
- const bits = modeBits(stateDirStat.mode);
- if (stateDirStat.isSymlink) {
+ const stateDirPerms = await inspectPathPermissions(params.stateDir, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (stateDirPerms.ok) {
+ if (stateDirPerms.isSymlink) {
findings.push({
checkId: "fs.state_dir.symlink",
severity: "warn",
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
});
}
- if (isWorldWritable(bits)) {
+ if (stateDirPerms.worldWritable) {
findings.push({
checkId: "fs.state_dir.perms_world_writable",
severity: "critical",
title: "State dir is world-writable",
- detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
- remediation: `chmod 700 ${params.stateDir}`,
+ detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.stateDir,
+ perms: stateDirPerms,
+ isDir: true,
+ posixMode: 0o700,
+ env: params.env,
+ }),
});
- } else if (isGroupWritable(bits)) {
+ } else if (stateDirPerms.groupWritable) {
findings.push({
checkId: "fs.state_dir.perms_group_writable",
severity: "warn",
title: "State dir is group-writable",
- detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
- remediation: `chmod 700 ${params.stateDir}`,
+ detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.stateDir,
+ perms: stateDirPerms,
+ isDir: true,
+ posixMode: 0o700,
+ env: params.env,
+ }),
});
- } else if (isGroupReadable(bits) || isWorldReadable(bits)) {
+ } else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
findings.push({
checkId: "fs.state_dir.perms_readable",
severity: "warn",
title: "State dir is readable by others",
- detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
- remediation: `chmod 700 ${params.stateDir}`,
+ detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.stateDir,
+ perms: stateDirPerms,
+ isDir: true,
+ posixMode: 0o700,
+ env: params.env,
+ }),
});
}
}
- const configStat = await safeStat(params.configPath);
- if (configStat.ok) {
- const bits = modeBits(configStat.mode);
- if (configStat.isSymlink) {
+ const configPerms = await inspectPathPermissions(params.configPath, {
+ env: params.env,
+ platform: params.platform,
+ exec: params.execIcacls,
+ });
+ if (configPerms.ok) {
+ if (configPerms.isSymlink) {
findings.push({
checkId: "fs.config.symlink",
severity: "warn",
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
});
}
- if (isWorldWritable(bits) || isGroupWritable(bits)) {
+ if (configPerms.worldWritable || configPerms.groupWritable) {
findings.push({
checkId: "fs.config.perms_writable",
severity: "critical",
title: "Config file is writable by others",
- detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
- remediation: `chmod 600 ${params.configPath}`,
+ detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.configPath,
+ perms: configPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
- } else if (isWorldReadable(bits)) {
+ } else if (configPerms.worldReadable) {
findings.push({
checkId: "fs.config.perms_world_readable",
severity: "critical",
title: "Config file is world-readable",
- detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
- remediation: `chmod 600 ${params.configPath}`,
+ detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.configPath,
+ perms: configPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
- } else if (isGroupReadable(bits)) {
+ } else if (configPerms.groupReadable) {
findings.push({
checkId: "fs.config.perms_group_readable",
severity: "warn",
title: "Config file is group-readable",
- detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
- remediation: `chmod 600 ${params.configPath}`,
+ detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
+ remediation: formatPermissionRemediation({
+ targetPath: params.configPath,
+ perms: configPerms,
+ isDir: false,
+ posixMode: 0o600,
+ env: params.env,
+ }),
});
}
}
@@ -274,7 +320,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({
checkId: "gateway.control_ui.insecure_auth",
- severity: "warn",
+ severity: "critical",
title: "Control UI allows insecure HTTP auth",
detail:
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
@@ -282,6 +328,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
});
}
+ if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
+ findings.push({
+ checkId: "gateway.control_ui.device_auth_disabled",
+ severity: "critical",
+ title: "DANGEROUS: Control UI device auth disabled",
+ detail:
+ "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
+ remediation: "Disable it unless you are in a short-lived break-glass scenario.",
+ });
+ }
+
const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
@@ -839,7 +896,9 @@ async function maybeProbeGateway(params: {
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise {
const findings: SecurityAuditFinding[] = [];
const cfg = opts.config;
- const env = process.env;
+ const env = opts.env ?? process.env;
+ const platform = opts.platform ?? process.platform;
+ const execIcacls = opts.execIcacls;
const stateDir = opts.stateDir ?? resolveStateDir(env);
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
@@ -862,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise {
+ const display = formatIcaclsResetCommand(params.path, {
+ isDir: params.require === "dir",
+ env: params.env,
+ });
+ try {
+ const st = await fs.lstat(params.path);
+ if (st.isSymbolicLink()) {
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ skipped: "symlink",
+ };
+ }
+ if (params.require === "dir" && !st.isDirectory()) {
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ skipped: "not-a-directory",
+ };
+ }
+ if (params.require === "file" && !st.isFile()) {
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ skipped: "not-a-file",
+ };
+ }
+ const cmd = createIcaclsResetCommand(params.path, {
+ isDir: st.isDirectory(),
+ env: params.env,
+ });
+ if (!cmd) {
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ skipped: "missing-user",
+ };
+ }
+ const exec = params.exec ?? runExec;
+ await exec(cmd.command, cmd.args);
+ return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
+ } catch (err) {
+ const code = (err as { code?: string }).code;
+ if (code === "ENOENT") {
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ skipped: "missing",
+ };
+ }
+ return {
+ kind: "icacls",
+ path: params.path,
+ command: display,
+ ok: false,
+ error: String(err),
+ };
+ }
+}
+
function setGroupPolicyAllowlist(params: {
cfg: ClawdbotConfig;
channel: string;
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
cfg: ClawdbotConfig;
- actions: SecurityFixChmodAction[];
+ actions: SecurityFixAction[];
+ applyPerms: (params: {
+ path: string;
+ mode: number;
+ require: "dir" | "file";
+ }) => Promise;
}): Promise {
const credsDir = resolveOAuthDir(params.env, params.stateDir);
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
// eslint-disable-next-line no-await-in-loop
- params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
+ params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop
- params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
+ params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
// eslint-disable-next-line no-await-in-loop
- params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
+ params.actions.push(
+ await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
+ );
const storePath = path.join(sessionsDir, "sessions.json");
// eslint-disable-next-line no-await-in-loop
- params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
+ params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
}
}
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
configPath?: string;
+ platform?: NodeJS.Platform;
+ exec?: ExecFn;
}): Promise {
const env = opts?.env ?? process.env;
+ const platform = opts?.platform ?? process.platform;
+ const exec = opts?.exec ?? runExec;
+ const isWindows = platform === "win32";
const stateDir = opts?.stateDir ?? resolveStateDir(env);
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
- const actions: SecurityFixChmodAction[] = [];
+ const actions: SecurityFixAction[] = [];
const errors: string[] = [];
const io = createConfigIO({ env, configPath });
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
}
}
- actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
- actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
+ const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
+ isWindows
+ ? safeAclReset({ path: params.path, require: params.require, env, exec })
+ : safeChmod({ path: params.path, mode: params.mode, require: params.require });
+
+ actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
+ actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
if (snap.exists) {
const includePaths = await collectIncludePathsRecursive({
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
}).catch(() => []);
for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop
- actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
+ actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
}
}
- await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch(
- (err) => {
- errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
- },
- );
+ await chmodCredentialsAndAgentState({
+ env,
+ stateDir,
+ cfg: snap.config ?? {},
+ actions,
+ applyPerms,
+ }).catch((err) => {
+ errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
+ });
return {
ok: errors.length === 0,
diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts
new file mode 100644
index 000000000..0a6779214
--- /dev/null
+++ b/src/security/windows-acl.ts
@@ -0,0 +1,203 @@
+import os from "node:os";
+
+import { runExec } from "../process/exec.js";
+
+export type ExecFn = typeof runExec;
+
+export type WindowsAclEntry = {
+ principal: string;
+ rights: string[];
+ rawRights: string;
+ canRead: boolean;
+ canWrite: boolean;
+};
+
+export type WindowsAclSummary = {
+ ok: boolean;
+ entries: WindowsAclEntry[];
+ untrustedWorld: WindowsAclEntry[];
+ untrustedGroup: WindowsAclEntry[];
+ trusted: WindowsAclEntry[];
+ error?: string;
+};
+
+const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
+const WORLD_PRINCIPALS = new Set([
+ "everyone",
+ "users",
+ "builtin\\users",
+ "authenticated users",
+ "nt authority\\authenticated users",
+]);
+const TRUSTED_BASE = new Set([
+ "nt authority\\system",
+ "system",
+ "builtin\\administrators",
+ "creator owner",
+]);
+const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
+const TRUSTED_SUFFIXES = ["\\administrators", "\\system"];
+
+const normalize = (value: string) => value.trim().toLowerCase();
+
+export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null {
+ const username = env?.USERNAME?.trim() || os.userInfo().username?.trim();
+ if (!username) return null;
+ const domain = env?.USERDOMAIN?.trim();
+ return domain ? `${domain}\\${username}` : username;
+}
+
+function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set {
+ const trusted = new Set(TRUSTED_BASE);
+ const principal = resolveWindowsUserPrincipal(env);
+ if (principal) {
+ trusted.add(normalize(principal));
+ const parts = principal.split("\\");
+ const userOnly = parts.at(-1);
+ if (userOnly) trusted.add(normalize(userOnly));
+ }
+ return trusted;
+}
+
+function classifyPrincipal(
+ principal: string,
+ env?: NodeJS.ProcessEnv,
+): "trusted" | "world" | "group" {
+ const normalized = normalize(principal);
+ const trusted = buildTrustedPrincipals(env);
+ if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s)))
+ return "trusted";
+ if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s)))
+ return "world";
+ return "group";
+}
+
+function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boolean } {
+ const upper = tokens.join("").toUpperCase();
+ const canWrite =
+ upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D");
+ const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R");
+ return { canRead, canWrite };
+}
+
+export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] {
+ const entries: WindowsAclEntry[] = [];
+ const normalizedTarget = targetPath.trim();
+ const lowerTarget = normalizedTarget.toLowerCase();
+ const quotedTarget = `"${normalizedTarget}"`;
+ const quotedLower = quotedTarget.toLowerCase();
+
+ for (const rawLine of output.split(/\r?\n/)) {
+ const line = rawLine.trimEnd();
+ if (!line.trim()) continue;
+ const trimmed = line.trim();
+ const lower = trimmed.toLowerCase();
+ if (
+ lower.startsWith("successfully processed") ||
+ lower.startsWith("processed") ||
+ lower.startsWith("failed processing") ||
+ lower.startsWith("no mapping between account names")
+ ) {
+ continue;
+ }
+
+ let entry = trimmed;
+ if (lower.startsWith(lowerTarget)) {
+ entry = trimmed.slice(normalizedTarget.length).trim();
+ } else if (lower.startsWith(quotedLower)) {
+ entry = trimmed.slice(quotedTarget.length).trim();
+ }
+ if (!entry) continue;
+
+ const idx = entry.indexOf(":");
+ if (idx === -1) continue;
+
+ const principal = entry.slice(0, idx).trim();
+ const rawRights = entry.slice(idx + 1).trim();
+ const tokens =
+ rawRights
+ .match(/\(([^)]+)\)/g)
+ ?.map((token) => token.slice(1, -1).trim())
+ .filter(Boolean) ?? [];
+ if (tokens.some((token) => token.toUpperCase() === "DENY")) continue;
+ const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
+ if (rights.length === 0) continue;
+ const { canRead, canWrite } = rightsFromTokens(rights);
+ entries.push({ principal, rights, rawRights, canRead, canWrite });
+ }
+
+ return entries;
+}
+
+export function summarizeWindowsAcl(
+ entries: WindowsAclEntry[],
+ env?: NodeJS.ProcessEnv,
+): Pick {
+ const trusted: WindowsAclEntry[] = [];
+ const untrustedWorld: WindowsAclEntry[] = [];
+ const untrustedGroup: WindowsAclEntry[] = [];
+ for (const entry of entries) {
+ const classification = classifyPrincipal(entry.principal, env);
+ if (classification === "trusted") trusted.push(entry);
+ else if (classification === "world") untrustedWorld.push(entry);
+ else untrustedGroup.push(entry);
+ }
+ return { trusted, untrustedWorld, untrustedGroup };
+}
+
+export async function inspectWindowsAcl(
+ targetPath: string,
+ opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn },
+): Promise {
+ const exec = opts?.exec ?? runExec;
+ try {
+ const { stdout, stderr } = await exec("icacls", [targetPath]);
+ const output = `${stdout}\n${stderr}`.trim();
+ const entries = parseIcaclsOutput(output, targetPath);
+ const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env);
+ return { ok: true, entries, trusted, untrustedWorld, untrustedGroup };
+ } catch (err) {
+ return {
+ ok: false,
+ entries: [],
+ trusted: [],
+ untrustedWorld: [],
+ untrustedGroup: [],
+ error: String(err),
+ };
+ }
+}
+
+export function formatWindowsAclSummary(summary: WindowsAclSummary): string {
+ if (!summary.ok) return "unknown";
+ const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup];
+ if (untrusted.length === 0) return "trusted-only";
+ return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", ");
+}
+
+export function formatIcaclsResetCommand(
+ targetPath: string,
+ opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
+): string {
+ const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%";
+ const grant = opts.isDir ? "(OI)(CI)F" : "F";
+ return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`;
+}
+
+export function createIcaclsResetCommand(
+ targetPath: string,
+ opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
+): { command: string; args: string[]; display: string } | null {
+ const user = resolveWindowsUserPrincipal(opts.env);
+ if (!user) return null;
+ const grant = opts.isDir ? "(OI)(CI)F" : "F";
+ const args = [
+ targetPath,
+ "/inheritance:r",
+ "/grant:r",
+ `${user}:${grant}`,
+ "/grant:r",
+ `SYSTEM:${grant}`,
+ ];
+ return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) };
+}
diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts
new file mode 100644
index 000000000..bfe70f005
--- /dev/null
+++ b/src/slack/monitor/media.test.ts
@@ -0,0 +1,278 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Store original fetch
+const originalFetch = globalThis.fetch;
+let mockFetch: ReturnType;
+
+describe("fetchWithSlackAuth", () => {
+ beforeEach(() => {
+ // Create a new mock for each test
+ mockFetch = vi.fn();
+ globalThis.fetch = mockFetch as typeof fetch;
+ });
+
+ afterEach(() => {
+ // Restore original fetch
+ globalThis.fetch = originalFetch;
+ vi.resetModules();
+ });
+
+ it("sends Authorization header on initial request with manual redirect", async () => {
+ // Import after mocking fetch
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ // Simulate direct 200 response (no redirect)
+ const mockResponse = new Response(Buffer.from("image data"), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+ mockFetch.mockResolvedValueOnce(mockResponse);
+
+ const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
+
+ expect(result).toBe(mockResponse);
+
+ // Verify fetch was called with correct params
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", {
+ headers: { Authorization: "Bearer xoxb-test-token" },
+ redirect: "manual",
+ });
+ });
+
+ it("follows redirects without Authorization header", async () => {
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ // First call: redirect response from Slack
+ const redirectResponse = new Response(null, {
+ status: 302,
+ headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" },
+ });
+
+ // Second call: actual file content from CDN
+ const fileResponse = new Response(Buffer.from("actual image data"), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+
+ mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
+
+ const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
+
+ expect(result).toBe(fileResponse);
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+
+ // First call should have Authorization header and manual redirect
+ expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", {
+ headers: { Authorization: "Bearer xoxb-test-token" },
+ redirect: "manual",
+ });
+
+ // Second call should follow the redirect without Authorization
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 2,
+ "https://cdn.slack-edge.com/presigned-url?sig=abc123",
+ { redirect: "follow" },
+ );
+ });
+
+ it("handles relative redirect URLs", async () => {
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ // Redirect with relative URL
+ const redirectResponse = new Response(null, {
+ status: 302,
+ headers: { location: "/files/redirect-target" },
+ });
+
+ const fileResponse = new Response(Buffer.from("image data"), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+
+ mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
+
+ await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token");
+
+ // Second call should resolve the relative URL against the original
+ expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", {
+ redirect: "follow",
+ });
+ });
+
+ it("returns redirect response when no location header is provided", async () => {
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ // Redirect without location header
+ const redirectResponse = new Response(null, {
+ status: 302,
+ // No location header
+ });
+
+ mockFetch.mockResolvedValueOnce(redirectResponse);
+
+ const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
+
+ // Should return the redirect response directly
+ expect(result).toBe(redirectResponse);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns 4xx/5xx responses directly without following", async () => {
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ const errorResponse = new Response("Not Found", {
+ status: 404,
+ });
+
+ mockFetch.mockResolvedValueOnce(errorResponse);
+
+ const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
+
+ expect(result).toBe(errorResponse);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("handles 301 permanent redirects", async () => {
+ const { fetchWithSlackAuth } = await import("./media.js");
+
+ const redirectResponse = new Response(null, {
+ status: 301,
+ headers: { location: "https://cdn.slack.com/new-url" },
+ });
+
+ const fileResponse = new Response(Buffer.from("image data"), {
+ status: 200,
+ });
+
+ mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse);
+
+ await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token");
+
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", {
+ redirect: "follow",
+ });
+ });
+});
+
+describe("resolveSlackMedia", () => {
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ globalThis.fetch = mockFetch as typeof fetch;
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ vi.resetModules();
+ });
+
+ it("prefers url_private_download over url_private", async () => {
+ // Mock the store module
+ vi.doMock("../../media/store.js", () => ({
+ saveMediaBuffer: vi.fn().mockResolvedValue({
+ path: "/tmp/test.jpg",
+ contentType: "image/jpeg",
+ }),
+ }));
+
+ const { resolveSlackMedia } = await import("./media.js");
+
+ const mockResponse = new Response(Buffer.from("image data"), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+ mockFetch.mockResolvedValueOnce(mockResponse);
+
+ await resolveSlackMedia({
+ files: [
+ {
+ url_private: "https://files.slack.com/private.jpg",
+ url_private_download: "https://files.slack.com/download.jpg",
+ name: "test.jpg",
+ },
+ ],
+ token: "xoxb-test-token",
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://files.slack.com/download.jpg",
+ expect.anything(),
+ );
+ });
+
+ it("returns null when download fails", async () => {
+ const { resolveSlackMedia } = await import("./media.js");
+
+ // Simulate a network error
+ mockFetch.mockRejectedValueOnce(new Error("Network error"));
+
+ const result = await resolveSlackMedia({
+ files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }],
+ token: "xoxb-test-token",
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it("returns null when no files are provided", async () => {
+ const { resolveSlackMedia } = await import("./media.js");
+
+ const result = await resolveSlackMedia({
+ files: [],
+ token: "xoxb-test-token",
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it("skips files without url_private", async () => {
+ const { resolveSlackMedia } = await import("./media.js");
+
+ const result = await resolveSlackMedia({
+ files: [{ name: "test.jpg" }], // No url_private
+ token: "xoxb-test-token",
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(result).toBeNull();
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("falls through to next file when first file returns error", async () => {
+ // Mock the store module
+ vi.doMock("../../media/store.js", () => ({
+ saveMediaBuffer: vi.fn().mockResolvedValue({
+ path: "/tmp/test.jpg",
+ contentType: "image/jpeg",
+ }),
+ }));
+
+ const { resolveSlackMedia } = await import("./media.js");
+
+ // First file: 404
+ const errorResponse = new Response("Not Found", { status: 404 });
+ // Second file: success
+ const successResponse = new Response(Buffer.from("image data"), {
+ status: 200,
+ headers: { "content-type": "image/jpeg" },
+ });
+
+ mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse);
+
+ const result = await resolveSlackMedia({
+ files: [
+ { url_private: "https://files.slack.com/first.jpg", name: "first.jpg" },
+ { url_private: "https://files.slack.com/second.jpg", name: "second.jpg" },
+ ],
+ token: "xoxb-test-token",
+ maxBytes: 1024 * 1024,
+ });
+
+ expect(result).not.toBeNull();
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts
index 143d6b36f..2674e2d50 100644
--- a/src/slack/monitor/media.ts
+++ b/src/slack/monitor/media.ts
@@ -5,6 +5,38 @@ import { fetchRemoteMedia } from "../../media/fetch.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { SlackFile } from "../types.js";
+/**
+ * Fetches a URL with Authorization header, handling cross-origin redirects.
+ * Node.js fetch strips Authorization headers on cross-origin redirects for security.
+ * Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that
+ * don't need the Authorization header, so we handle the initial auth request manually.
+ */
+export async function fetchWithSlackAuth(url: string, token: string): Promise {
+ // Initial request with auth and manual redirect handling
+ const initialRes = await fetch(url, {
+ headers: { Authorization: `Bearer ${token}` },
+ redirect: "manual",
+ });
+
+ // If not a redirect, return the response directly
+ if (initialRes.status < 300 || initialRes.status >= 400) {
+ return initialRes;
+ }
+
+ // Handle redirect - the redirected URL should be pre-signed and not need auth
+ const redirectUrl = initialRes.headers.get("location");
+ if (!redirectUrl) {
+ return initialRes;
+ }
+
+ // Resolve relative URLs against the original
+ const resolvedUrl = new URL(redirectUrl, url).toString();
+
+ // Follow the redirect without the Authorization header
+ // (Slack's CDN URLs are pre-signed and don't need it)
+ return fetch(resolvedUrl, { redirect: "follow" });
+}
+
export async function resolveSlackMedia(params: {
files?: SlackFile[];
token: string;
@@ -19,10 +51,12 @@ export async function resolveSlackMedia(params: {
const url = file.url_private_download ?? file.url_private;
if (!url) continue;
try {
- const fetchImpl: FetchLike = (input, init) => {
- const headers = new Headers(init?.headers);
- headers.set("Authorization", `Bearer ${params.token}`);
- return fetch(input, { ...init, headers });
+ // Note: We ignore init options because fetchWithSlackAuth handles
+ // redirect behavior specially. fetchRemoteMedia only passes the URL.
+ const fetchImpl: FetchLike = (input) => {
+ const inputUrl =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return fetchWithSlackAuth(inputUrl, params.token);
};
const fetched = await fetchRemoteMedia({
url,
diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts
new file mode 100644
index 000000000..5da5f0453
--- /dev/null
+++ b/src/telegram/bot-native-commands.plugin-auth.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { ChannelGroupPolicy } from "../config/group-policy.js";
+import type { ClawdbotConfig } from "../config/config.js";
+import type { TelegramAccountConfig } from "../config/types.js";
+import type { RuntimeEnv } from "../runtime.js";
+import { registerTelegramNativeCommands } from "./bot-native-commands.js";
+
+const getPluginCommandSpecs = vi.hoisted(() => vi.fn());
+const matchPluginCommand = vi.hoisted(() => vi.fn());
+const executePluginCommand = vi.hoisted(() => vi.fn());
+
+vi.mock("../plugins/commands.js", () => ({
+ getPluginCommandSpecs,
+ matchPluginCommand,
+ executePluginCommand,
+}));
+
+const deliverReplies = vi.hoisted(() => vi.fn(async () => {}));
+vi.mock("./bot/delivery.js", () => ({ deliverReplies }));
+
+vi.mock("./pairing-store.js", () => ({
+ readTelegramAllowFromStore: vi.fn(async () => []),
+}));
+
+describe("registerTelegramNativeCommands (plugin auth)", () => {
+ it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
+ const command = {
+ name: "plugin",
+ description: "Plugin command",
+ requireAuth: false,
+ handler: vi.fn(),
+ } as const;
+
+ getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]);
+ matchPluginCommand.mockReturnValue({ command, args: undefined });
+ executePluginCommand.mockResolvedValue({ text: "ok" });
+
+ const handlers: Record Promise> = {};
+ const bot = {
+ api: {
+ setMyCommands: vi.fn().mockResolvedValue(undefined),
+ sendMessage: vi.fn(),
+ },
+ command: (name: string, handler: (ctx: unknown) => Promise) => {
+ handlers[name] = handler;
+ },
+ } as const;
+
+ const cfg = {} as ClawdbotConfig;
+ const telegramCfg = {} as TelegramAccountConfig;
+ const resolveGroupPolicy = () =>
+ ({
+ allowlistEnabled: false,
+ allowed: true,
+ }) as ChannelGroupPolicy;
+
+ registerTelegramNativeCommands({
+ bot: bot as unknown as Parameters[0]["bot"],
+ cfg,
+ runtime: {} as RuntimeEnv,
+ accountId: "default",
+ telegramCfg,
+ allowFrom: ["999"],
+ groupAllowFrom: [],
+ replyToMode: "off",
+ textLimit: 4000,
+ useAccessGroups: false,
+ nativeEnabled: false,
+ nativeSkillsEnabled: false,
+ nativeDisabledExplicit: false,
+ resolveGroupPolicy,
+ resolveTelegramGroupConfig: () => ({
+ groupConfig: undefined,
+ topicConfig: undefined,
+ }),
+ shouldSkipUpdate: () => false,
+ opts: { token: "token" },
+ });
+
+ const ctx = {
+ message: {
+ chat: { id: 123, type: "private" },
+ from: { id: 111, username: "nope" },
+ message_id: 10,
+ date: 123456,
+ },
+ match: "",
+ };
+
+ await handlers.plugin?.(ctx);
+
+ expect(matchPluginCommand).toHaveBeenCalled();
+ expect(executePluginCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isAuthorizedSender: false,
+ }),
+ );
+ expect(deliverReplies).toHaveBeenCalledWith(
+ expect.objectContaining({
+ replies: [{ text: "ok" }],
+ }),
+ );
+ expect(bot.api.sendMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index 0f1cc1cb7..c33f1e18e 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -17,9 +17,18 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
+import {
+ normalizeTelegramCommandName,
+ TELEGRAM_COMMAND_NAME_PATTERN,
+} from "../config/telegram-custom-commands.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
+import {
+ executePluginCommand,
+ getPluginCommandSpecs,
+ matchPluginCommand,
+} from "../plugins/commands.js";
import type { ChannelGroupPolicy } from "../config/group-policy.js";
import type {
ReplyToMode,
@@ -42,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js";
type TelegramNativeCommandContext = Context & { match?: string };
+type TelegramCommandAuthResult = {
+ chatId: number;
+ isGroup: boolean;
+ isForum: boolean;
+ resolvedThreadId?: number;
+ senderId: string;
+ senderUsername: string;
+ groupConfig?: TelegramGroupConfig;
+ topicConfig?: TelegramTopicConfig;
+ commandAuthorized: boolean;
+};
+
type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: ClawdbotConfig;
@@ -65,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = {
opts: { token: string };
};
+async function resolveTelegramCommandAuth(params: {
+ msg: NonNullable;
+ bot: Bot;
+ cfg: ClawdbotConfig;
+ telegramCfg: TelegramAccountConfig;
+ allowFrom?: Array;
+ groupAllowFrom?: Array;
+ useAccessGroups: boolean;
+ resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
+ resolveTelegramGroupConfig: (
+ chatId: string | number,
+ messageThreadId?: number,
+ ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
+ requireAuth: boolean;
+}): Promise {
+ const {
+ msg,
+ bot,
+ cfg,
+ telegramCfg,
+ allowFrom,
+ groupAllowFrom,
+ useAccessGroups,
+ resolveGroupPolicy,
+ resolveTelegramGroupConfig,
+ requireAuth,
+ } = params;
+ const chatId = msg.chat.id;
+ const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
+ const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
+ const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
+ const resolvedThreadId = resolveTelegramForumThreadId({
+ isForum,
+ messageThreadId,
+ });
+ const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
+ const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
+ const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
+ const effectiveGroupAllow = normalizeAllowFromWithStore({
+ allowFrom: groupAllowOverride ?? groupAllowFrom,
+ storeAllowFrom,
+ });
+ const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
+ const senderIdRaw = msg.from?.id;
+ const senderId = senderIdRaw ? String(senderIdRaw) : "";
+ const senderUsername = msg.from?.username ?? "";
+
+ if (isGroup && groupConfig?.enabled === false) {
+ await bot.api.sendMessage(chatId, "This group is disabled.");
+ return null;
+ }
+ if (isGroup && topicConfig?.enabled === false) {
+ await bot.api.sendMessage(chatId, "This topic is disabled.");
+ return null;
+ }
+ if (requireAuth && isGroup && hasGroupAllowOverride) {
+ if (
+ senderIdRaw == null ||
+ !isSenderAllowed({
+ allow: effectiveGroupAllow,
+ senderId: String(senderIdRaw),
+ senderUsername,
+ })
+ ) {
+ await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ return null;
+ }
+ }
+
+ if (isGroup && useAccessGroups) {
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
+ const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
+ if (groupPolicy === "disabled") {
+ await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
+ return null;
+ }
+ if (groupPolicy === "allowlist" && requireAuth) {
+ if (
+ senderIdRaw == null ||
+ !isSenderAllowed({
+ allow: effectiveGroupAllow,
+ senderId: String(senderIdRaw),
+ senderUsername,
+ })
+ ) {
+ await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ return null;
+ }
+ }
+ const groupAllowlist = resolveGroupPolicy(chatId);
+ if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
+ await bot.api.sendMessage(chatId, "This group is not allowed.");
+ return null;
+ }
+ }
+
+ const dmAllow = normalizeAllowFromWithStore({
+ allowFrom: allowFrom,
+ storeAllowFrom,
+ });
+ const senderAllowed = isSenderAllowed({
+ allow: dmAllow,
+ senderId,
+ senderUsername,
+ });
+ const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
+ useAccessGroups,
+ authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
+ modeWhenAccessGroupsOff: "configured",
+ });
+ if (requireAuth && !commandAuthorized) {
+ await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ return null;
+ }
+
+ return {
+ chatId,
+ isGroup,
+ isForum,
+ resolvedThreadId,
+ senderId,
+ senderUsername,
+ groupConfig,
+ topicConfig,
+ commandAuthorized,
+ };
+}
+
export const registerTelegramNativeCommands = ({
bot,
cfg,
@@ -103,11 +252,50 @@ export const registerTelegramNativeCommands = ({
runtime.error?.(danger(issue.message));
}
const customCommands = customResolution.commands;
+ const pluginCommandSpecs = getPluginCommandSpecs();
+ const pluginCommands: Array<{ command: string; description: string }> = [];
+ const existingCommands = new Set(
+ [
+ ...nativeCommands.map((command) => command.name),
+ ...customCommands.map((command) => command.command),
+ ].map((command) => command.toLowerCase()),
+ );
+ const pluginCommandNames = new Set();
+ for (const spec of pluginCommandSpecs) {
+ const normalized = normalizeTelegramCommandName(spec.name);
+ if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
+ runtime.error?.(
+ danger(
+ `Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
+ ),
+ );
+ continue;
+ }
+ const description = spec.description.trim();
+ if (!description) {
+ runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`));
+ continue;
+ }
+ if (existingCommands.has(normalized)) {
+ runtime.error?.(
+ danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`),
+ );
+ continue;
+ }
+ if (pluginCommandNames.has(normalized)) {
+ runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`));
+ continue;
+ }
+ pluginCommandNames.add(normalized);
+ existingCommands.add(normalized);
+ pluginCommands.push({ command: normalized, description });
+ }
const allCommands: Array<{ command: string; description: string }> = [
...nativeCommands.map((command) => ({
command: command.name,
description: command.description,
})),
+ ...pluginCommands,
...customCommands,
];
@@ -124,99 +312,30 @@ export const registerTelegramNativeCommands = ({
const msg = ctx.message;
if (!msg) return;
if (shouldSkipUpdate(ctx)) return;
- const chatId = msg.chat.id;
- const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
- const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
- const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true;
- const resolvedThreadId = resolveTelegramForumThreadId({
+ const auth = await resolveTelegramCommandAuth({
+ msg,
+ bot,
+ cfg,
+ telegramCfg,
+ allowFrom,
+ groupAllowFrom,
+ useAccessGroups,
+ resolveGroupPolicy,
+ resolveTelegramGroupConfig,
+ requireAuth: true,
+ });
+ if (!auth) return;
+ const {
+ chatId,
+ isGroup,
isForum,
- messageThreadId,
- });
- const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
- const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
- const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
- const effectiveGroupAllow = normalizeAllowFromWithStore({
- allowFrom: groupAllowOverride ?? groupAllowFrom,
- storeAllowFrom,
- });
- const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
-
- if (isGroup && groupConfig?.enabled === false) {
- await bot.api.sendMessage(chatId, "This group is disabled.");
- return;
- }
- if (isGroup && topicConfig?.enabled === false) {
- await bot.api.sendMessage(chatId, "This topic is disabled.");
- return;
- }
- if (isGroup && hasGroupAllowOverride) {
- const senderId = msg.from?.id;
- const senderUsername = msg.from?.username ?? "";
- if (
- senderId == null ||
- !isSenderAllowed({
- allow: effectiveGroupAllow,
- senderId: String(senderId),
- senderUsername,
- })
- ) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
- return;
- }
- }
-
- if (isGroup && useAccessGroups) {
- const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
- const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
- if (groupPolicy === "disabled") {
- await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
- return;
- }
- if (groupPolicy === "allowlist") {
- const senderId = msg.from?.id;
- if (senderId == null) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
- return;
- }
- const senderUsername = msg.from?.username ?? "";
- if (
- !isSenderAllowed({
- allow: effectiveGroupAllow,
- senderId: String(senderId),
- senderUsername,
- })
- ) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
- return;
- }
- }
- const groupAllowlist = resolveGroupPolicy(chatId);
- if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
- await bot.api.sendMessage(chatId, "This group is not allowed.");
- return;
- }
- }
-
- const senderId = msg.from?.id ? String(msg.from.id) : "";
- const senderUsername = msg.from?.username ?? "";
- const dmAllow = normalizeAllowFromWithStore({
- allowFrom: allowFrom,
- storeAllowFrom,
- });
- const senderAllowed = isSenderAllowed({
- allow: dmAllow,
+ resolvedThreadId,
senderId,
senderUsername,
- });
- const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups,
- authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }],
- modeWhenAccessGroupsOff: "configured",
- });
- if (!commandAuthorized) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
- return;
- }
+ groupConfig,
+ topicConfig,
+ commandAuthorized,
+ } = auth;
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@@ -362,6 +481,66 @@ export const registerTelegramNativeCommands = ({
});
});
}
+
+ for (const pluginCommand of pluginCommands) {
+ bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
+ const msg = ctx.message;
+ if (!msg) return;
+ if (shouldSkipUpdate(ctx)) return;
+ const chatId = msg.chat.id;
+ const rawText = ctx.match?.trim() ?? "";
+ const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
+ const match = matchPluginCommand(commandBody);
+ if (!match) {
+ await bot.api.sendMessage(chatId, "Command not found.");
+ return;
+ }
+ const auth = await resolveTelegramCommandAuth({
+ msg,
+ bot,
+ cfg,
+ telegramCfg,
+ allowFrom,
+ groupAllowFrom,
+ useAccessGroups,
+ resolveGroupPolicy,
+ resolveTelegramGroupConfig,
+ requireAuth: match.command.requireAuth !== false,
+ });
+ if (!auth) return;
+ const { resolvedThreadId, senderId, commandAuthorized } = auth;
+
+ const result = await executePluginCommand({
+ command: match.command,
+ args: match.args,
+ senderId,
+ channel: "telegram",
+ isAuthorizedSender: commandAuthorized,
+ commandBody,
+ config: cfg,
+ });
+ const tableMode = resolveMarkdownTableMode({
+ cfg,
+ channel: "telegram",
+ accountId,
+ });
+ const chunkMode = resolveChunkMode(cfg, "telegram", accountId);
+
+ await deliverReplies({
+ replies: [result],
+ chatId: String(chatId),
+ token: opts.token,
+ runtime,
+ bot,
+ replyToMode,
+ textLimit,
+ messageThreadId: resolvedThreadId,
+ tableMode,
+ chunkMode,
+ linkPreview: telegramCfg.linkPreview,
+ });
+ });
+ }
}
} else if (nativeDisabledExplicit) {
bot.api.setMyCommands([]).catch((err) => {
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index 4edc91c8a..36a680227 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -17,6 +17,7 @@ import { isGifMedia } from "../../media/mime.js";
import { saveMediaBuffer } from "../../media/store.js";
import type { RuntimeEnv } from "../../runtime.js";
import { loadWebMedia } from "../../web/media.js";
+import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js";
import type { TelegramContext } from "./types.js";
@@ -80,9 +81,17 @@ export async function deliverReplies(params: {
: reply.mediaUrl
? [reply.mediaUrl]
: [];
+ const telegramData = reply.channelData?.telegram as
+ | { buttons?: Array> }
+ | undefined;
+ const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
if (mediaList.length === 0) {
const chunks = chunkText(reply.text || "");
- for (const chunk of chunks) {
+ for (let i = 0; i < chunks.length; i += 1) {
+ const chunk = chunks[i];
+ if (!chunk) continue;
+ // Only attach buttons to the first chunk.
+ const shouldAttachButtons = i === 0 && replyMarkup;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined,
@@ -90,6 +99,7 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
+ replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -125,10 +135,12 @@ export async function deliverReplies(params: {
first = false;
const replyToMessageId =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
+ const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
const mediaParams: Record = {
caption: htmlCaption,
reply_to_message_id: replyToMessageId,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
+ ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}),
};
if (threadParams) {
mediaParams.message_thread_id = threadParams.message_thread_id;
@@ -183,6 +195,7 @@ export async function deliverReplies(params: {
hasReplied,
messageThreadId,
linkPreview,
+ replyMarkup,
});
// Skip this media item; continue with next.
continue;
@@ -207,7 +220,8 @@ export async function deliverReplies(params: {
// Chunk it in case it's extremely long (same logic as text-only replies).
if (pendingFollowUpText && isFirstMedia) {
const chunks = chunkText(pendingFollowUpText);
- for (const chunk of chunks) {
+ for (let i = 0; i < chunks.length; i += 1) {
+ const chunk = chunks[i];
const replyToMessageIdFollowup =
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
await sendTelegramText(bot, chatId, chunk.html, runtime, {
@@ -216,6 +230,7 @@ export async function deliverReplies(params: {
textMode: "html",
plainText: chunk.text,
linkPreview,
+ replyMarkup: i === 0 ? replyMarkup : undefined,
});
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -277,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: {
hasReplied: boolean;
messageThreadId?: number;
linkPreview?: boolean;
+ replyMarkup?: ReturnType;
}): Promise {
const chunks = opts.chunkText(opts.text);
let hasReplied = opts.hasReplied;
- for (const chunk of chunks) {
+ for (let i = 0; i < chunks.length; i += 1) {
+ const chunk = chunks[i];
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
replyToMessageId:
opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined,
@@ -288,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: {
textMode: "html",
plainText: chunk.text,
linkPreview: opts.linkPreview,
+ replyMarkup: i === 0 ? opts.replyMarkup : undefined,
});
if (opts.replyToId && !hasReplied) {
hasReplied = true;
@@ -322,6 +340,7 @@ async function sendTelegramText(
textMode?: "markdown" | "html";
plainText?: string;
linkPreview?: boolean;
+ replyMarkup?: ReturnType;
},
): Promise {
const baseParams = buildTelegramSendParams({
@@ -337,6 +356,7 @@ async function sendTelegramText(
const res = await bot.api.sendMessage(chatId, htmlText, {
parse_mode: "HTML",
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
+ ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
});
return res.message_id;
@@ -347,6 +367,7 @@ async function sendTelegramText(
const fallbackText = opts?.plainText ?? text;
const res = await bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
+ ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
});
return res.message_id;
diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts
index e8163cbad..c68836b32 100644
--- a/src/wizard/onboarding.gateway-config.ts
+++ b/src/wizard/onboarding.gateway-config.ts
@@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding(
: ((await prompter.select({
message: "Gateway auth",
options: [
- {
- value: "off",
- label: "Off (loopback only)",
- hint: "Not recommended unless you fully trust local processes",
- },
{
value: "token",
label: "Token",
@@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding(
// Safety + constraints:
// - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once.
- // - Auth off only allowed for bind=loopback.
// - Funnel requires password auth.
if (tailscaleMode !== "off" && bind !== "loopback") {
await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note");
@@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding(
customBindHost = undefined;
}
- if (authMode === "off" && bind !== "loopback") {
- await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note");
- authMode = "token";
- }
-
if (tailscaleMode === "funnel" && authMode !== "password") {
await prompter.note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts
index 5c5590bf2..77b7f770d 100644
--- a/src/wizard/onboarding.ts
+++ b/src/wizard/onboarding.ts
@@ -51,12 +51,26 @@ async function requireRiskAcknowledgement(params: {
await params.prompter.note(
[
- "Please read: https://docs.clawd.bot/security",
+ "Security warning — please read.",
"",
- "Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).",
+ "Clawdbot is a hobby project and still in beta. Expect sharp edges.",
+ "This bot can read files and run actions if tools are enabled.",
+ "A bad prompt can trick it into doing unsafe things.",
"",
- "If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.",
- "Learn more: https://docs.clawd.bot/sandboxing",
+ "If you’re not comfortable with basic security and access control, don’t run Clawdbot.",
+ "Ask someone experienced to help before enabling tools or exposing it to the internet.",
+ "",
+ "Recommended baseline:",
+ "- Pairing/allowlists + mention gating.",
+ "- Sandbox + least-privilege tools.",
+ "- Keep secrets out of the agent’s reachable filesystem.",
+ "- Use the strongest available model for any bot with tools or untrusted inboxes.",
+ "",
+ "Run regularly:",
+ "clawdbot security audit --deep",
+ "clawdbot security audit --fix",
+ "",
+ "Must read: https://docs.clawd.bot/gateway/security",
].join("\n"),
"Security",
);
@@ -230,7 +244,6 @@ export async function runOnboardingWizard(
return "Auto";
};
const formatAuth = (value: GatewayAuthChoice) => {
- if (value === "off") return "Off (loopback only)";
if (value === "token") return "Token (default)";
return "Password";
};