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:

steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub zerone0x abhisekbasu1 + juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal - timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino - Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali orlyjamie - thewilloftheshadow sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev - joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella - neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] - John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter - cheeeee Josh Phillips robbyczgw-cla dlauer pookNast Whoaa512 YuriNachos chriseidhof ngutman ysqander - aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 - Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz - Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal ogulcancelik pasogott petradonka - rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- - Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse - Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx dguido EnzeD - erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi - longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML travisp - VAC william arzt zknicker abhaymundhara alejandro maza andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig - Lloyd loukotal louzhixian martinpucik Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman - nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann - Seredeep sergical shiv19 shiyuanhai siraht snopoke testingabc321 The Admiral thesash Ubuntu - voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik - pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + vignesh07 mteam88 joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest + benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 + stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko + denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst + KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg + Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea + kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg + azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer pookNast Whoaa512 + chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] + damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures + Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr + neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 adam91holt ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 + oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd + ClawdFx dguido EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey + jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen + Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz andrewting19 + anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 + Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis + Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 + Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps + RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht + snopoke testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

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"; };