diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 4892904..1548fac 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -10,11 +10,11 @@ This guide covers debugging the containerized agent execution system. ## Architecture Overview ``` -Host (macOS) Container (Linux VM) +Host (macOS/Linux) Container (Docker) ───────────────────────────────────────────────────────────── src/container-runner.ts container/agent-runner/ │ │ - │ spawns Apple Container │ runs Claude Agent SDK + │ spawns Docker container │ runs Claude Agent SDK │ with volume mounts │ with MCP servers │ │ ├── data/env/env ──────────────> /workspace/env-dir/env @@ -80,34 +80,30 @@ cat .env # Should show one of: ### 2. Environment Variables Not Passing -**Apple Container Bug:** Environment variables passed via `-e` are lost when using `-i` (interactive/piped stdin). - -**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed. +The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed. To verify env vars are reaching the container: ```bash -echo '{}' | container run -i \ - --mount type=bind,source=$(pwd)/data/env,target=/workspace/env-dir,readonly \ +echo '{}' | docker run -i \ + -v $(pwd)/data/env:/workspace/env-dir:ro \ --entrypoint /bin/bash nanoclaw-agent:latest \ -c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"' ``` ### 3. Mount Issues -**Apple Container quirks:** -- Only mounts directories, not individual files -- `-v` syntax does NOT support `:ro` suffix - use `--mount` for readonly: - ```bash - # Readonly: use --mount - --mount "type=bind,source=/path,target=/container/path,readonly" +Docker volume mount syntax: +```bash +# Readonly +-v /path:/container/path:ro - # Read-write: use -v - -v /path:/container/path - ``` +# Read-write +-v /path:/container/path +``` To check what's mounted inside a container: ```bash -container run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/' +docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/' ``` Expected structure: @@ -129,7 +125,7 @@ Expected structure: The container runs as user `node` (uid 1000). Check ownership: ```bash -container run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' +docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' whoami ls -la /workspace/ ls -la /app/ @@ -152,7 +148,7 @@ grep -A3 "Claude sessions" src/container-runner.ts **Verify sessions are accessible:** ```bash -container run --rm --entrypoint /bin/bash \ +docker run --rm --entrypoint /bin/bash \ -v ~/.claude:/home/node/.claude \ nanoclaw-agent:latest -c ' echo "HOME=$HOME" @@ -183,8 +179,8 @@ cp .env data/env/env # Run test query echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \ - container run -i \ - --mount "type=bind,source=$(pwd)/data/env,target=/workspace/env-dir,readonly" \ + docker run -i \ + -v $(pwd)/data/env:/workspace/env-dir:ro \ -v $(pwd)/groups/test:/workspace/group \ -v $(pwd)/data/ipc:/workspace/ipc \ nanoclaw-agent:latest @@ -192,8 +188,8 @@ echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMai ### Test Claude Code directly: ```bash -container run --rm --entrypoint /bin/bash \ - --mount "type=bind,source=$(pwd)/data/env,target=/workspace/env-dir,readonly" \ +docker run --rm --entrypoint /bin/bash \ + -v $(pwd)/data/env:/workspace/env-dir:ro \ nanoclaw-agent:latest -c ' export $(cat /workspace/env-dir/env | xargs) claude -p "Say hello" --dangerously-skip-permissions --allowedTools "" @@ -202,7 +198,7 @@ container run --rm --entrypoint /bin/bash \ ### Interactive shell in container: ```bash -container run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest +docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest ``` ## SDK Options Reference @@ -235,7 +231,7 @@ npm run build ./container/build.sh # Or force full rebuild -container builder prune -af +docker builder prune -af ./container/build.sh ``` @@ -243,10 +239,10 @@ container builder prune -af ```bash # List images -container images +docker images # Check what's in the image -container run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' +docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' echo "=== Node version ===" node --version @@ -326,11 +322,11 @@ echo -e "\n1. Authentication configured?" echo -e "\n2. Env file copied for container?" [ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run" -echo -e "\n3. Apple Container system running?" -container system status &>/dev/null && echo "OK" || echo "NOT RUNNING - NanoClaw should auto-start it; check logs" +echo -e "\n3. Docker running?" +docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)" echo -e "\n4. Container image exists?" -echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh" +echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh" echo -e "\n5. Session mount path correct?" grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/" diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index b4d6507..e92ca34 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -7,70 +7,39 @@ description: Run initial NanoClaw setup. Use when user wants to install dependen Run all commands automatically. Only pause when user action is required (scanning QR codes). +**UX Note:** When asking the user questions, prefer using the `AskUserQuestion` tool instead of just outputting text. This integrates with Claude's built-in question/answer system for a better experience. + ## 1. Install Dependencies ```bash npm install ``` -## 2. Install Container Runtime +## 2. Check Docker -First, detect the platform and check what's available: +Docker is required for running agents in isolated containers. ```bash echo "Platform: $(uname -s)" -which container && echo "Apple Container: installed" || echo "Apple Container: not installed" which docker && docker info >/dev/null 2>&1 && echo "Docker: installed and running" || echo "Docker: not installed or not running" ``` -### If NOT on macOS (Linux, etc.) - -Apple Container is macOS-only. Use Docker instead. - -Tell the user: -> You're on Linux, so we'll use Docker for container isolation. Let me set that up now. - -**Use the `/convert-to-docker` skill** to convert the codebase to Docker, then continue to Section 3. - -### If on macOS - -**If Apple Container is already installed:** Continue to Section 3. - -**If Apple Container is NOT installed:** Ask the user: -> NanoClaw needs a container runtime for isolated agent execution. You have two options: -> -> 1. **Apple Container** (default) - macOS-native, lightweight, designed for Apple silicon -> 2. **Docker** - Cross-platform, widely used, works on macOS and Linux -> -> Which would you prefer? - -#### Option A: Apple Container +**If Docker is NOT installed or NOT running:** Tell the user: -> Apple Container is required for running agents in isolated environments. +> Docker is required for running agents. Please install it: > -> 1. Download the latest `.pkg` from https://github.com/apple/container/releases -> 2. Double-click to install -> 3. Run `container system start` to start the service +> - **macOS**: Install [Docker Desktop](https://docker.com/products/docker-desktop) +> - **Linux**: Install [Docker Engine](https://docs.docker.com/engine/install/) > -> Let me know when you've completed these steps. +> Make sure Docker is running, then let me know. Wait for user confirmation, then verify: ```bash -container system start -container --version +docker info >/dev/null 2>&1 && echo "Docker OK" || echo "Docker not running" ``` -**Note:** NanoClaw automatically starts the Apple Container system when it launches, so you don't need to start it manually after reboots. - -#### Option B: Docker - -Tell the user: -> You've chosen Docker. Let me set that up now. - -**Use the `/convert-to-docker` skill** to convert the codebase to Docker, then continue to Section 3. - ## 3. Configure Claude Authentication Ask the user: @@ -78,23 +47,21 @@ Ask the user: ### Option 1: Claude Subscription (Recommended) -Ask the user: -> Want me to grab the OAuth token from your current Claude session? +Tell the user: +> Open another terminal window and run: +> ``` +> claude setup-token +> ``` +> A browser window will open for you to log in. Once authenticated, the token will be displayed in your terminal. Either: +> 1. Paste it here and I'll add it to `.env` for you, or +> 2. Add it to `.env` yourself as `CLAUDE_CODE_OAUTH_TOKEN=` + +If they give you the token, add it to `.env`: -If yes: ```bash -TOKEN=$(cat ~/.claude/.credentials.json 2>/dev/null | jq -r '.claudeAiOauth.accessToken // empty') -if [ -n "$TOKEN" ]; then - echo "CLAUDE_CODE_OAUTH_TOKEN=$TOKEN" > .env - echo "Token configured: ${TOKEN:0:20}...${TOKEN: -4}" -else - echo "No token found - are you logged in to Claude Code?" -fi +echo "CLAUDE_CODE_OAUTH_TOKEN=" > .env ``` -If the token wasn't found, tell the user: -> Run `claude` in another terminal and log in first, then come back here. - ### Option 2: API Key Ask if they have an existing key to copy or need to create one. @@ -127,95 +94,149 @@ Build the NanoClaw agent container: This creates the `nanoclaw-agent:latest` image with Node.js, Chromium, Claude Code CLI, and agent-browser. -Verify the build succeeded by running a simple test (this auto-detects which runtime you're using): +Verify the build succeeded: ```bash -if which docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" || echo "Container build failed" -else - echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" || echo "Container build failed" -fi +docker run --rm --entrypoint echo nanoclaw-agent:latest "Container OK" || echo "Container build failed" ``` ## 5. WhatsApp Authentication **USER ACTION REQUIRED** -Run the authentication script: - -```bash -npm run auth -``` +**IMPORTANT:** Run this command in the **foreground**. The QR code is multi-line ASCII art that must be displayed in full. Do NOT run in background or truncate the output. Tell the user: -> A QR code will appear. On your phone: +> A QR code will appear below. On your phone: > 1. Open WhatsApp > 2. Tap **Settings → Linked Devices → Link a Device** > 3. Scan the QR code +Run with a long Bash tool timeout (120000ms) so the user has time to scan. Do NOT use the `timeout` shell command (it's not available on macOS). + +```bash +npm run auth +``` + Wait for the script to output "Successfully authenticated" then continue. If it says "Already authenticated", skip to the next step. -## 6. Configure Assistant Name +## 6. Configure Assistant Name and Main Channel + +This step configures three things at once: the trigger word, the main channel type, and the main channel selection. + +### 6a. Ask for trigger word Ask the user: > What trigger word do you want to use? (default: `Andy`) > -> Messages starting with `@TriggerWord` will be sent to Claude. +> In group chats, messages starting with `@TriggerWord` will be sent to Claude. +> In your main channel (and optionally solo chats), no prefix is needed — all messages are processed. -If they choose something other than `Andy`, update it in these places: -1. `groups/CLAUDE.md` - Change "# Andy" and "You are Andy" to the new name -2. `groups/main/CLAUDE.md` - Same changes at the top -3. `data/registered_groups.json` - Use `@NewName` as the trigger when registering groups +Store their choice for use in the steps below. -Store their choice - you'll use it when creating the registered_groups.json and when telling them how to test. +### 6b. Explain security model and ask about main channel type -## 7. Register Main Channel +**Use the AskUserQuestion tool** to present this: -Ask the user: -> Do you want to use your **personal chat** (message yourself) or a **WhatsApp group** as your main control channel? +> **Important: Your "main" channel is your admin control portal.** +> +> The main channel has elevated privileges: +> - Can see messages from ALL other registered groups +> - Can manage and delete tasks across all groups +> - Can write to global memory that all groups can read +> - Has read-write access to the entire NanoClaw project +> +> **Recommendation:** Use your personal "Message Yourself" chat or a solo WhatsApp group as your main channel. This ensures only you have admin control. +> +> **Question:** Which setup will you use for your main channel? +> +> Options: +> 1. Personal chat (Message Yourself) - Recommended +> 2. Solo WhatsApp group (just me) +> 3. Group with other people (I understand the security implications) -For personal chat: -> Send any message to yourself in WhatsApp (the "Message Yourself" chat). Tell me when done. +If they choose option 3, ask a follow-up: -For group: -> Send any message in the WhatsApp group you want to use as your main channel. Tell me when done. +> You've chosen a group with other people. This means everyone in that group will have admin privileges over NanoClaw. +> +> Are you sure you want to proceed? The other members will be able to: +> - Read messages from your other registered chats +> - Schedule and manage tasks +> - Access any directories you've mounted +> +> Options: +> 1. Yes, I understand and want to proceed +> 2. No, let me use a personal chat or solo group instead + +### 6c. Register the main channel -After user confirms, start the app briefly to capture the message: +First build, then start the app briefly to connect to WhatsApp and sync group metadata. Use the Bash tool's timeout parameter (15000ms) — do NOT use the `timeout` shell command (it's not available on macOS). The app will be killed when the timeout fires, which is expected. ```bash -timeout 10 npm run dev || true +npm run build ``` -Then find the JID from the database: +Then run briefly (set Bash tool timeout to 15000ms): +```bash +npm run dev +``` +**For personal chat** (they chose option 1): + +Personal chats are NOT synced to the database on startup — only groups are. Instead, ask the user for their phone number (with country code, no + or spaces, e.g. `14155551234`), then construct the JID as `{number}@s.whatsapp.net`. + +**For group** (they chose option 2 or 3): + +Groups are synced on startup via `groupFetchAllParticipating`. Query the database for recent groups: ```bash -# For personal chat (ends with @s.whatsapp.net) -sqlite3 store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid LIKE '%@s.whatsapp.net' ORDER BY timestamp DESC LIMIT 5" +sqlite3 store/messages.db "SELECT jid, name FROM chats WHERE jid LIKE '%@g.us' AND jid != '__group_sync__' ORDER BY last_message_time DESC LIMIT 40" +``` -# For group (ends with @g.us) -sqlite3 store/messages.db "SELECT DISTINCT chat_jid FROM messages WHERE chat_jid LIKE '%@g.us' ORDER BY timestamp DESC LIMIT 5" +Show only the **10 most recent** group names to the user and ask them to pick one. If they say their group isn't in the list, show the next batch from the results you already have. If they tell you the group name directly, look it up: +```bash +sqlite3 store/messages.db "SELECT jid, name FROM chats WHERE name LIKE '%GROUP_NAME%' AND jid LIKE '%@g.us'" ``` -Create/update `data/registered_groups.json` using the JID from above and the assistant name from step 5: +### 6d. Write the configuration + +Once you have the JID, configure it. Use the assistant name from step 6a. + +For personal chats (solo, no prefix needed), set `requiresTrigger` to `false`: + ```json { "JID_HERE": { "name": "main", "folder": "main", "trigger": "@ASSISTANT_NAME", - "added_at": "CURRENT_ISO_TIMESTAMP" + "added_at": "CURRENT_ISO_TIMESTAMP", + "requiresTrigger": false } } ``` +For groups, keep `requiresTrigger` as `true` (default). + +Write to the database directly by creating a temporary registration script, or write `data/registered_groups.json` which will be auto-migrated on first run: + +```bash +mkdir -p data +``` + +Then write `data/registered_groups.json` with the correct JID, trigger, and timestamp. + +If the user chose a name other than `Andy`, also update: +1. `groups/global/CLAUDE.md` - Change "# Andy" and "You are Andy" to the new name +2. `groups/main/CLAUDE.md` - Same changes at the top + Ensure the groups folder exists: ```bash mkdir -p groups/main/logs ``` -## 8. Configure External Directory Access (Mount Allowlist) +## 7. Configure External Directory Access (Mount Allowlist) Ask the user: > Do you want the agent to be able to access any directories **outside** the NanoClaw project? @@ -242,7 +263,7 @@ Skip to the next step. If **yes**, ask follow-up questions: -### 8a. Collect Directory Paths +### 7a. Collect Directory Paths Ask the user: > Which directories do you want to allow access to? @@ -259,14 +280,14 @@ For each directory they provide, ask: > Read-write is needed for: code changes, creating files, git commits > Read-only is safer for: reference docs, config examples, templates -### 8b. Configure Non-Main Group Access +### 7b. Configure Non-Main Group Access Ask the user: > Should **non-main groups** (other WhatsApp chats you add later) be restricted to **read-only** access even if read-write is allowed for the directory? > > Recommended: **Yes** - this prevents other groups from modifying files even if you grant them access to a directory. -### 8c. Create the Allowlist +### 7c. Create the Allowlist Create the allowlist file based on their answers: @@ -317,12 +338,21 @@ Tell the user: > ```json > "containerConfig": { > "additionalMounts": [ -> { "hostPath": "~/projects/my-app", "containerPath": "my-app", "readonly": false } +> { "hostPath": "~/projects/my-app" } > ] > } > ``` +> The folder appears inside the container at `/workspace/extra/` (derived from the last segment of the path). Add `"readonly": false` for write access, or `"containerPath": "custom-name"` to override the default name. + +## 8. Configure Service + +Detect the OS and install the appropriate service: + +```bash +echo "Platform: $(uname -s)" +``` -## 9. Configure launchd Service +### macOS (launchd) Generate the plist file with correct paths automatically: @@ -382,10 +412,39 @@ Verify it's running: launchctl list | grep nanoclaw ``` -## 11. Test +### Linux (systemd) + +Generate the systemd service from the template: + +```bash +PROJECT_PATH=$(pwd) +sed -e "s|{{PROJECT_ROOT}}|${PROJECT_PATH}|g" -e "s|{{USER}}|$(whoami)|g" \ + systemd/groupguard.service > /tmp/groupguard.service + +sudo cp /tmp/groupguard.service /etc/systemd/system/groupguard.service +sudo systemctl daemon-reload +sudo systemctl enable groupguard +``` + +Build and start the service: + +```bash +npm run build +mkdir -p logs +sudo systemctl start groupguard +``` + +Verify it's running: +```bash +sudo systemctl status groupguard +``` + +## 9. Test Tell the user (using the assistant name they configured): > Send `@ASSISTANT_NAME hello` in your registered chat. +> +> **Tip:** In your main channel, you don't need the `@` prefix — just send `hello` and the agent will respond. Check the logs: ```bash @@ -399,14 +458,14 @@ The user should receive a response in WhatsApp. **Service not starting**: Check `logs/nanoclaw.error.log` **Container agent fails with "Claude Code process exited with code 1"**: -- Ensure the container runtime is running: - - Apple Container: `container system start` - - Docker: `docker info` (start Docker Desktop on macOS, or `sudo systemctl start docker` on Linux) +- Ensure Docker is running: `docker info` (start Docker Desktop on macOS, or `sudo systemctl start docker` on Linux) - Check container logs: `cat groups/main/logs/container-*.log | tail -50` **No response to messages**: - Verify the trigger pattern matches (e.g., `@AssistantName` at start of message) -- Check that the chat JID is in `data/registered_groups.json` +- Main channel doesn't require a prefix — all messages are processed +- Personal/solo chats with `requiresTrigger: false` also don't need a prefix +- Check that the chat JID is in the database: `sqlite3 store/messages.db "SELECT * FROM registered_groups"` - Check `logs/nanoclaw.log` for errors **WhatsApp disconnected**: @@ -414,7 +473,12 @@ The user should receive a response in WhatsApp. - Run `npm run auth` to re-authenticate - Restart the service: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` -**Unload service**: +**Unload service (macOS)**: ```bash launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist ``` + +**Stop service (Linux)**: +```bash +sudo systemctl stop groupguard +``` diff --git a/CLAUDE.md b/CLAUDE.md index 1fd6a78..53946d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,22 @@ -# NanoClaw +# GroupGuard -Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. +WhatsApp group moderation bot built on NanoClaw. See [README.md](README.md) for features and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. ## Quick Context -Single Node.js process that connects to WhatsApp, routes messages to Claude Agent SDK running in Apple Container (Linux VMs). Each group has isolated filesystem and memory. +Single Node.js process that connects to WhatsApp, runs moderation guards on the host, and routes messages to Claude Agent SDK running in Docker containers. Each group has isolated filesystem, memory, and guard configuration. ## Key Files | File | Purpose | |------|---------| | `src/index.ts` | Main app: WhatsApp connection, message routing, IPC | +| `src/moderator.ts` | Guard evaluation, DM enforcement, admin caching | +| `src/guards/` | Guard implementations (content, property, behavioral, keyword) | | `src/config.ts` | Trigger pattern, paths, intervals | | `src/container-runner.ts` | Spawns agent containers with mounts | | `src/task-scheduler.ts` | Runs scheduled tasks | -| `src/db.ts` | SQLite operations | +| `src/db.ts` | SQLite operations (messages, moderation logs) | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | ## Skills @@ -27,7 +29,7 @@ Single Node.js process that connects to WhatsApp, routes messages to Claude Agen ## Development -Run commands directly—don't tell the user to run them. +Run commands directly — don't tell the user to run them. ```bash npm run dev # Run with hot reload @@ -35,8 +37,15 @@ npm run build # Compile TypeScript ./container/build.sh # Rebuild agent container ``` -Service management: +Service management (macOS): ```bash launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist ``` + +Service management (Linux): +```bash +sudo systemctl start groupguard +sudo systemctl stop groupguard +sudo systemctl restart groupguard +``` diff --git a/README.md b/README.md index 0c0644f..f80c1d8 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,229 @@ -

- NanoClaw -

+# GroupGuard -

- My personal Claude assistant that runs securely in containers. Lightweight and built to be understood and customized for your own needs. -

+WhatsApp group moderation powered by Claude. Automated content filtering, spam prevention, and natural-language admin controls — all running in isolated containers. -## Why I Built This +Built on [NanoClaw](https://github.com/gavrielc/nanoclaw). -[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project with a great vision. But I can't sleep well running software I don't understand with access to my life. OpenClaw has 52+ modules, 8 config management files, 45+ dependencies, and abstractions for 15 channel providers. Security is application-level (allowlists, pairing codes) rather than OS isolation. Everything runs in one Node process with shared memory. +## What It Does -NanoClaw gives you the same core functionality in a codebase you can understand in 8 minutes. One process. A handful of files. Agents run in actual Linux containers with filesystem isolation, not behind permission checks. +GroupGuard sits in your WhatsApp groups and enforces rules automatically. Messages that violate rules get deleted instantly, and the sender gets a private explanation. Admins control everything through natural language — just tell the bot what you want. -## Quick Start - -```bash -git clone https://github.com/gavrielc/nanoclaw.git -cd nanoclaw -claude +``` +@GroupGuard enable no-spam and no-links for Family Chat +@GroupGuard set observation mode for Work Team (log violations but don't delete) +@GroupGuard show moderation stats for the last week +@GroupGuard disable quiet-hours for the Main group ``` -Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup, service configuration. - -## Philosophy - -**Small enough to understand.** One process, a few source files. No microservices, no message queues, no abstraction layers. Have Claude Code walk you through it. - -**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker). They can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. - -**Built for one user.** This isn't a framework. It's working software that fits my exact needs. You fork it and have Claude Code make it match your exact needs. - -**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that this is safe. +Beyond moderation, it's a full Claude assistant — it can answer questions, search the web, schedule tasks, and manage files. The moderation just runs silently in the background. -**AI-native.** No installation wizard; Claude Code guides setup. No monitoring dashboard; ask Claude what's happening. No debugging tools; describe the problem, Claude fixes it. +## Guards -**Skills over features.** Contributors shouldn't add features (e.g. support for Telegram) to the codebase. Instead, they contribute [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need. +14 built-in rules organized into four categories: -**Best harness, best model.** This runs on Claude Agent SDK, which means you're running Claude Code directly. The harness matters. A bad harness makes even smart models seem dumb, a good harness gives them superpowers. Claude Code is (IMO) the best harness available. +**Content Type** — what format is allowed +| Guard | What it does | +|-------|-------------| +| `text-only` | Only allow text messages | +| `video-only` | Only allow video messages | +| `voice-only` | Only allow voice notes | +| `media-only` | Only allow media, block text | +| `no-stickers` | Block stickers | +| `no-images` | Block images | -**No ToS gray areas.** Because it uses Claude Agent SDK natively with no hacks or workarounds, using your subscription with your auth token is completely legitimate (I think). No risk of being shut down for terms of service violations (I am not a lawyer). +**Content Property** — message characteristics +| Guard | What it does | Config | +|-------|-------------|--------| +| `no-links` | Block URLs | — | +| `no-forwarded` | Block forwarded messages | — | +| `max-text-length` | Block long messages | `maxLength` (default: 2000) | +| `keyword-filter` | Block keywords or regex patterns | `keywords`, `patterns` | -## What It Supports +**Behavioral** — rate limiting and access control +| Guard | What it does | Config | +|-------|-------------|--------| +| `no-spam` | Block rapid-fire messages | `maxMessages` (5), `windowSeconds` (10) | +| `slow-mode` | One message per N minutes | `intervalMinutes` (5) | +| `quiet-hours` | Block during certain hours | `startHour` (22), `endHour` (7) | +| `approved-senders` | Whitelist-only mode | `allowedJids` | -- **WhatsApp I/O** - Message Claude from your phone -- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted -- **Main channel** - Your private channel (self-chat) for admin control; every other group is completely isolated -- **Scheduled tasks** - Recurring jobs that run Claude and can message you back -- **Web access** - Search and fetch content -- **Container isolation** - Agents sandboxed in Apple Container (macOS) or Docker (macOS/Linux) -- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills +Guards run on the host process, not inside the container — enforcement is instant. Messages blocked by guards never reach the database. -## Usage - -Talk to your assistant with the trigger word (default: `@Andy`): +## How Moderation Works ``` -@Andy send an overview of the sales pipeline every weekday morning at 9am (has access to my Obsidian vault folder) -@Andy review the git history for the past week each Friday and update the README if there's drift -@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing +Message arrives + | + v +Is sender a group admin? --> Yes: skip guards (if adminExempt=true) + | + No + v +Run all enabled guards for this group + | + v +Any guard blocks? --> No: store message, process normally + | + Yes + v +Observation mode? --> Yes: log violation, store message anyway + | + No + v +Delete message + DM sender with reason + log violation ``` -From the main channel (your self-chat), you can manage groups and tasks: -``` -@Andy list all scheduled tasks across groups -@Andy pause the Monday briefing task -@Andy join the Family Chat group +Each group has independent guard configurations and a moderation config: + +```json +{ + "guards": [ + { "guardId": "no-spam", "enabled": true, "params": { "maxMessages": 5 } }, + { "guardId": "no-links", "enabled": true } + ], + "moderationConfig": { + "observationMode": false, + "adminExempt": true, + "dmCooldownSeconds": 60 + } +} ``` -## Customizing +- **Observation mode**: Log violations without deleting — useful for testing rules before enforcing +- **Admin exempt**: Group admins bypass all guards +- **DM cooldown**: Prevent notification spam (one DM per user per 60s) -There are no configuration files to learn. Just tell Claude Code what you want: +All violations are logged to SQLite with timestamp, sender, guard ID, action, and reason. -- "Change the trigger word to @Bob" -- "Remember in the future to make responses shorter and more direct" -- "Add a custom greeting when I say good morning" -- "Store conversation summaries weekly" - -Or run `/customize` for guided changes. - -The codebase is small enough that Claude can safely modify it. - -## Contributing - -**Don't add features. Add skills.** +## Quick Start -If you want to add Telegram support, don't create a PR that adds Telegram alongside WhatsApp. Instead, contribute a skill file (`.claude/skills/add-telegram/SKILL.md`) that teaches Claude Code how to transform a NanoClaw installation to use Telegram. +```bash +git clone git@github.com:TomGranot/groupguard.git +cd groupguard +./setup.sh +``` -Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case. +Or use Claude Code for guided setup: run `claude` then `/setup`. -### RFS (Request for Skills) +**Requirements:** Node.js 20+, Docker, [Claude Code](https://claude.ai/download) (for API key) -Skills we'd love to see: +## Architecture -**Communication Channels** -- `/add-telegram` - Add Telegram as channel. Should give the user option to replace WhatsApp or add as additional channel. Also should be possible to add it as a control channel (where it can trigger actions) or just a channel that can be used in actions triggered elsewhere -- `/add-slack` - Add Slack -- `/add-discord` - Add Discord +``` +WhatsApp (baileys) --> Guard filter --> SQLite --> Polling loop --> Docker (Claude Agent SDK) --> Response +``` -**Platform Support** -- `/setup-windows` - Windows via WSL2 + Docker +Single Node.js process. Moderation runs on the host for instant enforcement. Agent responses run in isolated Docker containers with mounted directories. Per-group message queues. IPC via filesystem. -**Session Management** -- `/add-clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK. +Key files: +- `src/index.ts` — Main app: WhatsApp connection, message routing, IPC +- `src/moderator.ts` — Guard evaluation, DM enforcement, admin caching +- `src/guards/` — Guard implementations (content, property, behavioral, keyword) +- `src/container-runner.ts` — Spawns streaming agent containers +- `src/task-scheduler.ts` — Runs scheduled tasks +- `src/db.ts` — SQLite operations (messages, moderation logs, groups, sessions) +- `groups/*/CLAUDE.md` — Per-group memory + +## Features + +- **14 moderation guards** — Content filtering, spam prevention, rate limiting, keyword blocking +- **Observation mode** — Test rules without enforcing them +- **WhatsApp I/O** — Message Claude from your phone, manage groups naturally +- **Isolated group context** — Each group has its own memory, filesystem, and container sandbox +- **Main channel** — Your private admin control channel with elevated privileges +- **Scheduled tasks** — Recurring jobs that run Claude and can message you back +- **Web access** — Search and fetch content +- **Container isolation** — Agents sandboxed in Docker containers (macOS/Linux) +- **Moderation logging** — Full audit trail in SQLite -## Requirements +## Usage -- macOS or Linux -- Node.js 20+ -- [Claude Code](https://claude.ai/download) -- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) +Talk to your bot with the trigger word (default: `@GroupGuard`): -## Architecture +``` +@GroupGuard enable no-spam for this group +@GroupGuard show me the last 10 moderation violations +@GroupGuard add a keyword filter blocking "crypto" and "forex" +@GroupGuard schedule a daily summary of moderation activity at 9am +``` +From the main channel, you have admin control over all groups: ``` -WhatsApp (baileys) --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response +@GroupGuard list all groups and their guard configs +@GroupGuard enable observation mode for Work Team +@GroupGuard show moderation stats across all groups ``` -Single Node.js process. Agents execute in isolated Linux containers with mounted directories. IPC via filesystem. No daemons, no queues, no complexity. +## Deploying to a Server -Key files: -- `src/index.ts` - Main app: WhatsApp connection, routing, IPC -- `src/container-runner.ts` - Spawns agent containers -- `src/task-scheduler.ts` - Runs scheduled tasks -- `src/db.ts` - SQLite operations -- `groups/*/CLAUDE.md` - Per-group memory +GroupGuard needs Docker to spawn agent containers, which rules out most managed platforms — you need a real VM. -## FAQ +### Option 1: One-Click Deploy (exe.dev) — $20/month -**Why WhatsApp and not Telegram/Signal/etc?** +The fastest path. [exe.dev](https://exe.dev) gives you a VM with Docker pre-installed and an AI agent that sets everything up. -Because I use WhatsApp. Fork it and run a skill to change it. That's the whole point. +After the VM is provisioned (~5 min), authenticate WhatsApp: -**Why Apple Container instead of Docker?** +```bash +ssh .exe.xyz +cd /opt/groupguard && npm run auth # scan QR code with your phone +sudo systemctl start groupguard # start the service +``` -On macOS, Apple Container is lightweight, fast, and optimized for Apple silicon. But Docker is also fully supported—during `/setup`, you can choose which runtime to use. On Linux, Docker is used automatically. +### Option 2: Budget VPS (Hetzner) — ~$4/month -**Can I run this on Linux?** +Best value. [Hetzner Cloud](https://www.hetzner.com/cloud/) with dedicated resources. -Yes. Run `/setup` and it will automatically configure Docker as the container runtime. Thanks to [@dotsetgreg](https://github.com/dotsetgreg) for contributing the `/convert-to-docker` skill. +1. Create a server: **CX22** (2 vCPU, 4 GB RAM, 40 GB disk), Docker CE app image, Ubuntu 24.04 +2. SSH in and run: -**Is this secure?** +```bash +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash +source ~/.bashrc && nvm install 22 -Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. +git clone git@github.com:TomGranot/groupguard.git /opt/groupguard +cd /opt/groupguard +echo 'ANTHROPIC_API_KEY=your-key-here' > .env +./setup.sh -**Why no configuration files?** +npm run auth # scan QR code +sudo systemctl start groupguard # start the service +``` -We don't want configuration sprawl. Every user should customize it to so that the code matches exactly what they want rather than configuring a generic system. If you like having config files, tell Claude to add them. +### Other Options -**How do I debug issues?** +| Provider | Cost | Notes | +|----------|------|-------| +| **DigitalOcean** | $6-12/mo | Docker 1-Click image | +| **Vultr** | $6-10/mo | Startup scripts | +| **Linode/Akamai** | $5/mo+ | StackScripts | +| **Oracle Cloud** | Free | ARM A1 (hard to provision) | +| **Local macOS** | Free | Docker Desktop + launchd via `./setup.sh` | -Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach. +## Troubleshooting -**Why isn't the setup working for me?** +- **Docker not running** — macOS: start Docker Desktop. Linux: `sudo systemctl start docker` +- **WhatsApp auth expired** — Run `npm run auth` to re-authenticate, then restart +- **Service not starting** — Check `logs/nanoclaw.log` and `logs/nanoclaw.error.log` +- **No response to messages** — Check the trigger pattern, verify the group is registered +- **Guards not working** — Check moderation logs: `sqlite3 store/messages.db "SELECT * FROM moderation_log ORDER BY timestamp DESC LIMIT 10"` +- **Container networking on macOS** — Docker Desktop handles this automatically. If using colima/lima, run `sudo ./scripts/macos-networking.sh` -I don't know. Run `claude`, then run `/debug`. If claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md. +Run `/debug` in Claude Code for guided troubleshooting. -**What changes will be accepted into the codebase?** +## Customizing + +The codebase is small enough to modify safely. Tell Claude Code what you want: -Security fixes, bug fixes, and clear improvements to the base configuration. That's it. +- "Add a new guard that blocks messages with more than 3 emojis" +- "Change the DM message format when a message is blocked" +- "Add a daily moderation report that gets sent to the admin group" + +Or run `/customize` for guided changes. -Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills. +## Based On -This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want. +GroupGuard is built on [NanoClaw](https://github.com/gavrielc/nanoclaw), a lightweight personal Claude assistant. NanoClaw provides the core architecture (WhatsApp connection, container isolation, scheduling, IPC) and GroupGuard adds the moderation layer on top. ## License diff --git a/container/Dockerfile b/container/Dockerfile index d8c6b4e..1cdff3c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y \ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +# Prefer IPv4 DNS results to avoid IPv6 routing issues with vmnet NAT +ENV NODE_OPTIONS="--dns-result-order=ipv4first" + # Install agent-browser and claude-code globally RUN npm install -g agent-browser @anthropic-ai/claude-code @@ -51,7 +54,7 @@ RUN npm run build RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks # Create entrypoint script -# Sources env from mounted /workspace/env-dir/env if it exists (workaround for Apple Container -i bug) +# Sources env from mounted /workspace/env-dir/env if it exists RUN printf '#!/bin/bash\nset -e\n[ -f /workspace/env-dir/env ] && export $(cat /workspace/env-dir/env | xargs)\ncat > /tmp/input.json\nnode /app/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index f05a6f4..2c7bb53 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -189,7 +189,7 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu lines.push(''); for (const msg of messages) { - const sender = msg.role === 'user' ? 'User' : 'Andy'; + const sender = msg.role === 'user' ? 'User' : 'GroupGuard'; const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + '...' : msg.content; @@ -272,6 +272,7 @@ async function main(): Promise { result, newSessionId }); + process.exit(0); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/container/build.sh b/container/build.sh index 8de24d5..6fb235f 100755 --- a/container/build.sh +++ b/container/build.sh @@ -12,12 +12,12 @@ TAG="${1:-latest}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" -# Build with Apple Container -container build -t "${IMAGE_NAME}:${TAG}" . +# Build with Docker +docker build -t "${IMAGE_NAME}:${TAG}" . echo "" echo "Build complete!" echo "Image: ${IMAGE_NAME}:${TAG}" echo "" echo "Test with:" -echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | container run -i ${IMAGE_NAME}:${TAG}" +echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | docker run -i ${IMAGE_NAME}:${TAG}" diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 0ccaa1e..40dc234 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -20,7 +20,7 @@ The entire codebase should be something you can read and understand. One Node.js ### Security Through True Isolation -Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers (Apple Container). The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. +Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers (Docker). The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. ### Built for One User @@ -54,13 +54,7 @@ Skills to add or switch to different messaging platforms: - `/add-sms` - Add SMS via Twilio or similar - `/convert-to-telegram` - Replace WhatsApp with Telegram entirely -### Container Runtime -The project currently uses Apple Container (macOS-only). We need: -- `/convert-to-docker` - Replace Apple Container with standard Docker -- This unlocks Linux support and broader deployment options - ### Platform Support -- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) - `/setup-windows` - Windows support via WSL2 + Docker --- @@ -71,7 +65,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. **Core components:** - **Claude Agent SDK** as the core agent -- **Apple Container** for isolated agent execution (Linux VMs) +- **Docker** for isolated agent execution (Linux containers) - **WhatsApp** as the primary I/O channel - **Persistent memory** per conversation and globally - **Scheduled tasks** that run Claude and can message back @@ -104,7 +98,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - Sessions auto-compact when context gets too long, preserving critical information ### Container Isolation -- All agents run inside Apple Container (lightweight Linux VMs) +- All agents run inside Docker containers - Each agent invocation spawns a container with mounted directories - Containers provide filesystem isolation - agents can only see mounted paths - Bash access is safe because commands run inside the container, not on the host @@ -175,7 +169,8 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) ### Deployment -- Runs on local Mac via launchd +- macOS: runs via launchd service +- Linux: runs via systemd service - Single Node.js process handles everything --- diff --git a/docs/SECURITY.md b/docs/SECURITY.md index eabbd76..07eb5e5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -13,7 +13,7 @@ ### 1. Container Isolation (Primary Boundary) -Agents execute in Apple Container (lightweight Linux VMs), providing: +Agents execute in Docker containers, providing: - **Process isolation** - Container processes cannot affect the host - **Filesystem isolation** - Only explicitly mounted directories are visible - **Non-root execution** - Runs as unprivileged `node` user (uid 1000) diff --git a/docs/SPEC.md b/docs/SPEC.md index 013be0e..8da5935 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -45,7 +45,7 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per │ │ spawns container │ │ ▼ │ ├─────────────────────────────────────────────────────────────────────┤ -│ APPLE CONTAINER (Linux VM) │ +│ DOCKER CONTAINER │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ AGENT RUNNER │ │ @@ -75,7 +75,7 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per |-----------|------------|---------| | WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages | | Message Storage | SQLite (better-sqlite3) | Store messages for polling | -| Container Runtime | Apple Container | Isolated Linux VMs for agent execution | +| Container Runtime | Docker | Isolated Linux containers for agent execution | | Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers | | Browser Automation | agent-browser + Chromium | Web interaction and screenshots | | Runtime | Node.js 20+ | Host process for routing and scheduling | @@ -105,7 +105,7 @@ nanoclaw/ │ ├── db.ts # Database initialization and queries │ ├── whatsapp-auth.ts # Standalone WhatsApp authentication │ ├── task-scheduler.ts # Runs scheduled tasks when due -│ └── container-runner.ts # Spawns agents in Apple Containers +│ └── container-runner.ts # Spawns agents in Docker containers │ ├── container/ │ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI) @@ -187,7 +187,7 @@ export const IPC_POLL_INTERVAL = 1000; export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); ``` -**Note:** Paths must be absolute for Apple Container volume mounts to work correctly. +**Note:** Paths must be absolute for Docker volume mounts to work correctly. ### Container Configuration @@ -216,7 +216,7 @@ Groups can have additional directories mounted via `containerConfig` in `data/re Additional mounts appear at `/workspace/extra/{containerPath}` inside the container. -**Apple Container mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix doesn't work). +**Docker mount syntax:** Both read-write (`-v host:container`) and readonly (`-v host:container:ro`) use the `-v` flag. ### Claude Authentication @@ -233,7 +233,7 @@ The token can be extracted from `~/.claude/.credentials.json` if you're logged i ANTHROPIC_API_KEY=sk-ant-api03-... ``` -Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and mounted into the container at `/workspace/env-dir/env`, then sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. This workaround is needed because Apple Container loses `-e` environment variables when using `-i` (interactive mode with piped stdin). +Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and mounted into the container at `/workspace/env-dir/env`, then sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. ### Changing the Assistant Name @@ -479,12 +479,12 @@ The `nanoclaw` MCP server is created dynamically per agent call with the current ## Deployment -NanoClaw runs as a single macOS launchd service. +NanoClaw runs as a launchd service (macOS) or systemd service (Linux). ### Startup Sequence When NanoClaw starts, it: -1. **Ensures Apple Container system is running** - Automatically starts it if needed (survives reboots) +1. **Ensures Docker is running** 2. Initializes the SQLite database 3. Loads state (registered groups, sessions, router state) 4. Connects to WhatsApp @@ -555,7 +555,7 @@ tail -f logs/nanoclaw.log ### Container Isolation -All agents run inside Apple Container (lightweight Linux VMs), providing: +All agents run inside Docker containers, providing: - **Filesystem isolation**: Agents can only access mounted directories - **Safe Bash access**: Commands run inside the container, not on your Mac - **Network isolation**: Can be configured per-container if needed @@ -603,7 +603,7 @@ chmod 700 groups/ | Issue | Cause | Solution | |-------|-------|----------| | No response to messages | Service not running | Check `launchctl list | grep nanoclaw` | -| "Claude Code process exited with code 1" | Apple Container failed to start | Check logs; NanoClaw auto-starts container system but may fail | +| "Claude Code process exited with code 1" | Docker not running | Check logs; NanoClaw auto-starts container system but may fail | | "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` | | Session not continuing | Session ID not saved | Check `data/sessions.json` | | Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` | diff --git a/docs/apple-container-networking.md b/docs/apple-container-networking.md new file mode 100644 index 0000000..6bde195 --- /dev/null +++ b/docs/apple-container-networking.md @@ -0,0 +1,90 @@ +# Apple Container Networking Setup (macOS 26) + +Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs). + +## Quick Setup + +Run these two commands (requires `sudo`): + +```bash +# 1. Enable IP forwarding so the host routes container traffic +sudo sysctl -w net.inet.ip.forwarding=1 + +# 2. Enable NAT so container traffic gets masqueraded through your internet interface +echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - +``` + +> **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface` + +## Making It Persistent + +These settings reset on reboot. To make them permanent: + +**IP Forwarding** — add to `/etc/sysctl.conf`: +``` +net.inet.ip.forwarding=1 +``` + +**NAT Rules** — add to `/etc/pf.conf` (before any existing rules): +``` +nat on en0 from 192.168.64.0/24 to any -> (en0) +``` + +Then reload: `sudo pfctl -f /etc/pf.conf` + +## IPv6 DNS Issue + +By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail. + +The container image and runner are configured to prefer IPv4 via: +``` +NODE_OPTIONS=--dns-result-order=ipv4first +``` + +This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`. + +## Verification + +```bash +# Check IP forwarding is enabled +sysctl net.inet.ip.forwarding +# Expected: net.inet.ip.forwarding: 1 + +# Test container internet access +container run --rm --entrypoint curl nanoclaw-agent:latest \ + -s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com +# Expected: 404 + +# Check bridge interface (only exists when a container is running) +ifconfig bridge100 +``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | +| HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | +| `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | +| Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | + +## How It Works + +``` +Container VM (192.168.64.x) + │ + ├── eth0 → gateway 192.168.64.1 + │ +bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs + │ + ├── IP forwarding (sysctl) routes packets from bridge100 → en0 + │ + ├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP + │ +en0 (your WiFi/Ethernet) → Internet +``` + +## References + +- [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26 +- [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 0865d9f..1c841de 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -1,6 +1,6 @@ -# Andy +# GroupGuard -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +You are GroupGuard, a WhatsApp group moderation bot. You help admins manage their groups with automated content moderation, spam prevention, and natural-language admin controls. ## What You Can Do @@ -10,26 +10,30 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Run bash commands in your sandbox - Schedule tasks to run later or on a recurring basis - Send messages back to the chat +- **Manage group moderation** — enable/disable guards, view moderation logs, configure rules -## Long Tasks +## Communication -If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: +Your output is sent to the user or group. -1. Send a brief message: what you understood and what you'll do -2. Do the work -3. Exit with the final answer +You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work. -This keeps users informed instead of waiting in silence. +### Internal thoughts + +If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: + +``` +Checking the moderation config for this group... + +Here are the active guards for this group... +``` + +Text inside `` tags is logged but not sent to the user. ## Scheduled Tasks When you run as a scheduled task (no direct user message), use `mcp__nanoclaw__send_message` if needed to communicate with the user. Your return value is only logged internally - it won't be sent to the user. -Example: If your task is "Share the weather forecast", you should: -1. Get the weather data -2. Call `mcp__nanoclaw__send_message` with the formatted forecast -3. Return a brief summary for the logs - ## Your Workspace Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 70f54d2..21ce491 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,6 +1,6 @@ -# Andy +# GroupGuard -You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders. +You are GroupGuard, a WhatsApp group moderation bot. You help admins manage their groups with automated content moderation, spam prevention, and natural-language admin controls. ## What You Can Do @@ -10,42 +10,17 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Run bash commands in your sandbox - Schedule tasks to run later or on a recurring basis - Send messages back to the chat +- **Manage group moderation** — enable/disable guards, view moderation logs, configure rules -## Long Tasks +## Communication -If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: +Your output is sent to the user or group. -1. Send a brief message: what you understood and what you'll do -2. Do the work -3. Exit with the final answer +You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. -This keeps users informed instead of waiting in silence. +### Internal thoughts -## Memory - -The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. - -When you learn something important: -- Create files for structured data (e.g., `customers.md`, `preferences.md`) -- Split files larger than 500 lines into folders -- Add recurring context directly to this CLAUDE.md -- Always index new memory files at the top of CLAUDE.md - -## Qwibit Ops Access - -You have access to Qwibit operations data at `/workspace/extra/qwibit-ops/` with these key areas: - -- **sales/** - Pipeline, deals, playbooks, pitch materials (see `sales/CLAUDE.md`) -- **clients/** - Active accounts, service delivery, client management (see `clients/CLAUDE.md`) -- **company/** - Strategy, thesis, operational philosophy (see `company/CLAUDE.md`) - -Read the CLAUDE.md files in each folder for role-specific context and workflows. - -**Key context:** -- Qwibit is a B2B GEO (Generative Engine Optimization) agency -- Pricing: $2,000-$4,000/month, month-to-month contracts -- Team: Gavriel (founder, sales & client work), Lazer (founder, dealflow), Ali (PM) -- Obsidian-based workflow with Kanban boards (PIPELINE.md, PORTFOLIO.md) +Wrap internal reasoning in `` tags — logged but not sent to the user. ## WhatsApp Formatting @@ -130,75 +105,142 @@ Groups are registered in `/workspace/project/data/registered_groups.json`: "1234567890-1234567890@g.us": { "name": "Family Chat", "folder": "family-chat", - "trigger": "@Andy", - "added_at": "2024-01-31T12:00:00.000Z" + "trigger": "@GroupGuard", + "added_at": "2024-01-31T12:00:00.000Z", + "guards": [ + { "guardId": "no-spam", "enabled": true, "params": { "maxMessages": 5, "windowSeconds": 10 } } + ], + "moderationConfig": { + "observationMode": false, + "adminExempt": true, + "dmCooldownSeconds": 60 + } } } ``` Fields: -- **Key**: The WhatsApp JID (unique identifier for the chat) -- **name**: Display name for the group -- **folder**: Folder name under `groups/` for this group's files and memory -- **trigger**: The trigger word (usually same as global, but could differ) -- **added_at**: ISO timestamp when registered +- **guards**: Array of guard configurations. Each has a `guardId`, `enabled` flag, and optional `params`. +- **moderationConfig**: Controls enforcement behavior: + - `observationMode: true` = log only, don't delete messages + - `observationMode: false` = auto-enforce (delete + DM) + - `adminExempt: true` = admins bypass all guards + - `dmCooldownSeconds` = minimum seconds between DMs to the same user -### Adding a Group +--- -1. Query the database to find the group's JID -2. Read `/workspace/project/data/registered_groups.json` -3. Add the new group entry with `containerConfig` if needed -4. Write the updated JSON back -5. Create the group folder: `/workspace/project/groups/{folder-name}/` -6. Optionally create an initial `CLAUDE.md` for the group +## Guard Management -Example folder name conventions: -- "Family Chat" → `family-chat` -- "Work Team" → `work-team` -- Use lowercase, hyphens instead of spaces +### Available Guards -#### Adding Additional Directories for a Group +Query the database or read the source to list guards. Here's the full list: -Groups can have extra directories mounted. Add `containerConfig` to their entry: +**Content Type Guards:** +| Guard ID | Description | +|----------|-------------| +| `text-only` | Only text messages allowed | +| `video-only` | Only video messages allowed | +| `voice-only` | Only voice notes allowed | +| `media-only` | Only media (images/video/audio/docs) allowed | +| `no-stickers` | Block stickers | +| `no-images` | Block images | -```json +**Content Property Guards:** +| Guard ID | Description | Params | +|----------|-------------|--------| +| `no-links` | Block URLs | — | +| `no-forwarded` | Block forwarded messages | — | +| `max-text-length` | Block long messages | `maxLength` (default: 2000) | +| `keyword-filter` | Block keywords/regex patterns | `keywords` (string[]), `patterns` (regex string[]) | + +**Behavioral Guards:** +| Guard ID | Description | Params | +|----------|-------------|--------| +| `quiet-hours` | Block during hours | `startHour` (default: 22), `endHour` (default: 7) | +| `slow-mode` | 1 msg per N minutes | `intervalMinutes` (default: 5) | +| `no-spam` | Rate limit rapid messages | `maxMessages` (default: 5), `windowSeconds` (default: 10) | +| `approved-senders` | Whitelist only | `allowedJids` (string[]) | + +### Enabling/Disabling Guards (IPC) + +**IMPORTANT:** Do NOT edit `registered_groups.json` directly — the host process won't reload it. Use the `update_group_config` IPC command instead, which updates the live config immediately: + +```bash +# Enable guards for a group +cat > /workspace/ipc/tasks/config_$(date +%s%N).json << 'EOF' { - "1234567890@g.us": { - "name": "Dev Team", - "folder": "dev-team", - "trigger": "@Andy", - "added_at": "2026-01-31T12:00:00Z", - "containerConfig": { - "additionalMounts": [ - { - "hostPath": "/Users/gavriel/projects/webapp", - "containerPath": "webapp", - "readonly": false - } - ] - } + "type": "update_group_config", + "jid": "120363422834835417@g.us", + "guards": [ + { "guardId": "no-spam", "enabled": true }, + { "guardId": "no-links", "enabled": true }, + { "guardId": "keyword-filter", "enabled": true, "params": { "keywords": ["spam", "promo"] } } + ], + "moderationConfig": { + "observationMode": false, + "adminExempt": true, + "dmCooldownSeconds": 60 } } +EOF ``` -The directory will appear at `/workspace/extra/webapp` in that group's container. +To disable a specific guard, send the full guards array with that guard removed or set `enabled: false`. -### Removing a Group +To update only moderation config (without changing guards), omit the `guards` field: +```bash +cat > /workspace/ipc/tasks/config_$(date +%s%N).json << 'EOF' +{ + "type": "update_group_config", + "jid": "120363422834835417@g.us", + "moderationConfig": { + "observationMode": true, + "adminExempt": true, + "dmCooldownSeconds": 60 + } +} +EOF +``` -1. Read `/workspace/project/data/registered_groups.json` -2. Remove the entry for that group -3. Write the updated JSON back -4. The group folder and its files remain (don't delete them) +### Viewing Moderation Logs -### Listing Groups +```bash +sqlite3 /workspace/project/store/messages.db " + SELECT timestamp, sender_jid, guard_id, action, reason + FROM moderation_log + WHERE chat_jid = '120363336345536173@g.us' + ORDER BY timestamp DESC + LIMIT 20; +" +``` -Read `/workspace/project/data/registered_groups.json` and format it nicely. +### Moderation Stats + +```bash +sqlite3 /workspace/project/store/messages.db " + SELECT guard_id, COUNT(*) as violations + FROM moderation_log + WHERE chat_jid = '120363336345536173@g.us' + GROUP BY guard_id + ORDER BY violations DESC; +" +``` --- -## Global Memory +## Responding to Admin Requests + +When an admin asks to manage guards, follow this pattern: + +1. **"Enable X guard for Y group"**: Read current config from `registered_groups.json`, build the updated guards array, send `update_group_config` IPC command. +2. **"Disable X guard for Y group"**: Same as above, but with guard removed or `enabled: false`. +3. **"Show moderation stats for Y group"**: Query `moderation_log` table. +4. **"Enable observation mode"**: Send `update_group_config` IPC with `moderationConfig.observationMode: true`. +5. **"Start enforcing"**: Send `update_group_config` IPC with `moderationConfig.observationMode: false`. + +Always read the CURRENT config from `registered_groups.json` first, then modify and send via IPC. This ensures you don't overwrite other settings. -You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar. +Always confirm the action back to the admin. --- diff --git a/package.json b/package.json index cfa6b78..1332c6d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "nanoclaw", - "version": "1.0.0", - "description": "Personal Claude assistant. Lightweight, secure, customizable.", + "name": "groupguard", + "version": "0.1.0", + "description": "WhatsApp group moderation bot. Built on NanoClaw.", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/scripts/macos-networking.sh b/scripts/macos-networking.sh new file mode 100755 index 0000000..fa3e77f --- /dev/null +++ b/scripts/macos-networking.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# macOS networking setup for non-Desktop Docker (colima, lima, etc.) +# Docker Desktop handles networking automatically — this script is only needed +# for alternative Docker installations on macOS. +# +# Usage: sudo ./scripts/macos-networking.sh + +set -e + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run with sudo:" + echo " sudo $0" + exit 1 +fi + +# Check if Docker Desktop is handling networking +if docker info 2>/dev/null | grep -q "Desktop"; then + echo "Docker Desktop detected — networking is handled automatically." + echo "You don't need this script." + exit 0 +fi + +# Detect the active internet interface +INTERFACE=$(route get 8.8.8.8 2>/dev/null | grep interface | awk '{print $2}') +if [[ -z "$INTERFACE" ]]; then + echo "Could not detect active network interface. Defaulting to en0." + INTERFACE="en0" +fi + +echo "Active interface: $INTERFACE" + +# Step 1: Enable IP forwarding +echo "Enabling IP forwarding..." +sysctl -w net.inet.ip.forwarding=1 + +# Step 2: Add NAT rule +echo "Adding NAT rule for Docker bridge network..." +echo "nat on $INTERFACE from 192.168.64.0/24 to any -> ($INTERFACE)" | pfctl -ef - 2>/dev/null + +echo "" +echo "Networking configured. These settings will reset on reboot." +echo "" + +# Step 3: Create launchd daemon for persistence +read -p "Make persistent across reboots? (y/N) " -n 1 -r +echo "" + +if [[ $REPLY =~ ^[Yy]$ ]]; then + PLIST="/Library/LaunchDaemons/com.groupguard.networking.plist" + + cat > "$PLIST" << EOF + + + + + Label + com.groupguard.networking + ProgramArguments + + /bin/bash + -c + sysctl -w net.inet.ip.forwarding=1 && echo "nat on $INTERFACE from 192.168.64.0/24 to any -> ($INTERFACE)" | pfctl -ef - + + RunAtLoad + + + +EOF + + launchctl load "$PLIST" 2>/dev/null || true + echo "Persistence configured via $PLIST" +fi + +echo "Done." diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..864247d --- /dev/null +++ b/setup.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# GroupGuard / NanoClaw One-Command Setup +# +# Usage: ./setup.sh +# +# Prerequisites: Node.js 20+, Docker + +set -e + +echo "=== GroupGuard Setup ===" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +ok() { echo -e " ${GREEN}OK${NC} $1"; } +fail() { echo -e " ${RED}FAIL${NC} $1"; exit 1; } +warn() { echo -e " ${YELLOW}WARN${NC} $1"; } + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +# --- Step 1: Check Node.js --- +echo "Step 1: Checking Node.js..." +if ! command -v node &>/dev/null; then + fail "Node.js not found. Install Node.js 20+ from https://nodejs.org" +fi + +NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) +if [[ "$NODE_VERSION" -lt 20 ]]; then + fail "Node.js $NODE_VERSION found, but 20+ is required. Update from https://nodejs.org" +fi +ok "Node.js $(node -v)" + +# --- Step 2: Check Docker --- +echo "Step 2: Checking Docker..." +if ! command -v docker &>/dev/null; then + fail "Docker not found. Install from https://docker.com/products/docker-desktop" +fi + +if ! docker info &>/dev/null; then + fail "Docker is not running. Start Docker Desktop (macOS) or run 'sudo systemctl start docker' (Linux)" +fi +ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')" + +# --- Step 3: Install dependencies --- +echo "Step 3: Installing dependencies..." +npm install --silent +ok "npm packages installed" + +# --- Step 4: Build container image --- +echo "Step 4: Building container image..." +./container/build.sh +if docker run --rm --entrypoint echo nanoclaw-agent:latest "OK" &>/dev/null; then + ok "Container image built and verified" +else + fail "Container image build failed" +fi + +# --- Step 5: WhatsApp authentication --- +echo "Step 5: WhatsApp authentication..." +if [[ -d "store/auth" ]] && [[ -f "store/auth/creds.json" ]]; then + ok "Already authenticated (store/auth/ exists)" +else + echo "" + echo " A QR code will appear. Scan it with your phone:" + echo " WhatsApp > Settings > Linked Devices > Link a Device" + echo "" + npm run auth + echo "" + ok "WhatsApp authenticated" +fi + +# --- Step 6: Build TypeScript --- +echo "Step 6: Building TypeScript..." +npm run build --silent +ok "TypeScript compiled" + +# --- Step 7: Install service --- +echo "Step 7: Installing service..." + +OS="$(uname -s)" +case "$OS" in + Darwin) + echo " Detected: macOS" + + # Generate launchd plist + NODE_PATH=$(which node) + HOME_PATH="$HOME" + + cat > ~/Library/LaunchAgents/com.nanoclaw.plist << EOF + + + + + Label + com.nanoclaw + ProgramArguments + + ${NODE_PATH} + ${PROJECT_ROOT}/dist/index.js + + WorkingDirectory + ${PROJECT_ROOT} + RunAtLoad + + KeepAlive + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin + HOME + ${HOME_PATH} + + StandardOutPath + ${PROJECT_ROOT}/logs/nanoclaw.log + StandardErrorPath + ${PROJECT_ROOT}/logs/nanoclaw.error.log + + +EOF + + mkdir -p logs + launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist 2>/dev/null || true + ok "launchd service installed and started" + + # Check if Docker Desktop handles networking + if ! docker info 2>/dev/null | grep -q "Desktop"; then + warn "Non-Desktop Docker detected. You may need to run: sudo ./scripts/macos-networking.sh" + fi + ;; + + Linux) + echo " Detected: Linux" + + # Generate systemd service from template + SERVICE_FILE="/tmp/groupguard.service" + sed -e "s|{{PROJECT_ROOT}}|${PROJECT_ROOT}|g" \ + -e "s|{{USER}}|$(whoami)|g" \ + "$PROJECT_ROOT/systemd/groupguard.service" > "$SERVICE_FILE" + + echo " Installing systemd service (requires sudo)..." + sudo cp "$SERVICE_FILE" /etc/systemd/system/groupguard.service + sudo systemctl daemon-reload + sudo systemctl enable groupguard + sudo systemctl start groupguard + rm -f "$SERVICE_FILE" + ok "systemd service installed and started" + ;; + + *) + warn "Unknown OS: $OS. Service not installed. Run manually with: npm run start" + ;; +esac + +# --- Done --- +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Your assistant is running! Send a message in WhatsApp to test." +echo "" +echo "Useful commands:" +echo " npm run dev - Run in development mode (with hot reload)" +echo " npm run auth - Re-authenticate WhatsApp" +echo "" + +case "$OS" in + Darwin) + echo "Service management (macOS):" + echo " launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist # Stop" + echo " launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Start" + ;; + Linux) + echo "Service management (Linux):" + echo " sudo systemctl status groupguard # Check status" + echo " sudo systemctl restart groupguard # Restart" + echo " sudo systemctl stop groupguard # Stop" + echo " journalctl -u groupguard -f # View logs" + ;; +esac diff --git a/src/config.ts b/src/config.ts index 7675d21..a42430d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import path from 'path'; -export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; +export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'GroupGuard'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; diff --git a/src/container-runner.ts b/src/container-runner.ts index 3cb0b47..a32a3e8 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,6 +1,6 @@ /** * Container Runner for NanoClaw - * Spawns agent execution in Apple Container and handles IPC + * Spawns agent execution in Docker and handles IPC */ import { spawn } from 'child_process'; @@ -85,7 +85,6 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount }); // Global memory directory (read-only for non-main) - // Apple Container only supports directory mounts, not file mounts const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { mounts.push({ @@ -117,7 +116,7 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount readonly: false }); - // Environment file directory (workaround for Apple Container -i env var bug) + // Environment file directory // Only expose specific auth variables needed by Claude Code, not the entire .env const envDir = path.join(DATA_DIR, 'env'); fs.mkdirSync(envDir, { recursive: true }); @@ -157,12 +156,11 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount } function buildContainerArgs(mounts: VolumeMount[]): string[] { - const args: string[] = ['run', '-i', '--rm']; + const args: string[] = ['run', '-i', '--rm', '-e', 'NODE_OPTIONS=--dns-result-order=ipv4first']; - // Apple Container: --mount for readonly, -v for read-write for (const mount of mounts) { if (mount.readonly) { - args.push('--mount', `type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`); + args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`); } else { args.push('-v', `${mount.hostPath}:${mount.containerPath}`); } @@ -201,7 +199,7 @@ export async function runContainerAgent( fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { - const container = spawn('container', containerArgs, { + const container = spawn('docker', containerArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); diff --git a/src/db.ts b/src/db.ts index 0a61867..c54ca14 100644 --- a/src/db.ts +++ b/src/db.ts @@ -60,6 +60,22 @@ export function initDatabase(): void { CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at); `); + // Moderation log table + db.exec(` + CREATE TABLE IF NOT EXISTS moderation_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_jid TEXT NOT NULL, + sender_jid TEXT NOT NULL, + guard_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL, + message_id TEXT, + timestamp TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_modlog_chat ON moderation_log(chat_jid, timestamp); + CREATE INDEX IF NOT EXISTS idx_modlog_sender ON moderation_log(sender_jid, timestamp); + `); + // Add sender_name column if it doesn't exist (migration for existing DBs) try { db.exec(`ALTER TABLE messages ADD COLUMN sender_name TEXT`); @@ -282,3 +298,50 @@ export function getTaskRunLogs(taskId: string, limit = 10): TaskRunLog[] { LIMIT ? `).all(taskId, limit) as TaskRunLog[]; } + +// --- Moderation log --- + +export interface ModerationLogEntry { + chat_jid: string; + sender_jid: string; + guard_id: string; + action: string; + reason: string; + message_id: string; + timestamp: string; +} + +export function logModeration(entry: ModerationLogEntry): void { + db.prepare(` + INSERT INTO moderation_log (chat_jid, sender_jid, guard_id, action, reason, message_id, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + entry.chat_jid, + entry.sender_jid, + entry.guard_id, + entry.action, + entry.reason, + entry.message_id, + entry.timestamp, + ); +} + +export function getModerationLogs(chatJid: string, limit = 50): ModerationLogEntry[] { + return db.prepare(` + SELECT chat_jid, sender_jid, guard_id, action, reason, message_id, timestamp + FROM moderation_log + WHERE chat_jid = ? + ORDER BY timestamp DESC + LIMIT ? + `).all(chatJid, limit) as ModerationLogEntry[]; +} + +export function getModerationStats(chatJid: string): Array<{ guard_id: string; count: number }> { + return db.prepare(` + SELECT guard_id, COUNT(*) as count + FROM moderation_log + WHERE chat_jid = ? + GROUP BY guard_id + ORDER BY count DESC + `).all(chatJid) as Array<{ guard_id: string; count: number }>; +} diff --git a/src/guards/behavioral.ts b/src/guards/behavioral.ts new file mode 100644 index 0000000..3ede567 --- /dev/null +++ b/src/guards/behavioral.ts @@ -0,0 +1,127 @@ +import { Guard, GuardContext, GuardResult } from './types.js'; + +const pass: GuardResult = { blocked: false }; + +function block(guardId: string, reason: string): GuardResult { + return { blocked: true, guardId, reason }; +} + +/** + * In-memory rate limiting state. + * Key: `${chatJid}:${senderJid}`, Value: array of timestamps (ms). + */ +const messageTimes = new Map(); + +// Clean up old entries every 10 minutes +setInterval(() => { + const cutoff = Date.now() - 10 * 60 * 1000; + for (const [key, times] of messageTimes) { + const filtered = times.filter((t) => t > cutoff); + if (filtered.length === 0) { + messageTimes.delete(key); + } else { + messageTimes.set(key, filtered); + } + } +}, 10 * 60 * 1000); + +function getKey(chatJid: string, senderJid: string): string { + return `${chatJid}:${senderJid}`; +} + +function recordMessage(chatJid: string, senderJid: string, now: number): void { + const key = getKey(chatJid, senderJid); + const times = messageTimes.get(key) || []; + times.push(now); + messageTimes.set(key, times); +} + +function getRecentCount(chatJid: string, senderJid: string, windowMs: number, now: number): number { + const key = getKey(chatJid, senderJid); + const times = messageTimes.get(key) || []; + const cutoff = now - windowMs; + return times.filter((t) => t > cutoff).length; +} + +export const quietHoursGuard: Guard = { + id: 'quiet-hours', + name: 'Quiet Hours', + description: 'Block messages during specified hours. Set params.startHour and params.endHour (0-23, default: 22-07).', + evaluate: (ctx: GuardContext): GuardResult => { + const startHour = (ctx.config.params?.startHour as number) ?? 22; + const endHour = (ctx.config.params?.endHour as number) ?? 7; + const hour = ctx.now.getHours(); + + let isQuiet: boolean; + if (startHour < endHour) { + isQuiet = hour >= startHour && hour < endHour; + } else { + isQuiet = hour >= startHour || hour < endHour; + } + + if (isQuiet) { + return block('quiet-hours', `This group is in quiet hours (${startHour}:00 - ${endHour}:00). Please try again later.`); + } + return pass; + }, +}; + +export const slowModeGuard: Guard = { + id: 'slow-mode', + name: 'Slow Mode', + description: 'Limit users to 1 message per N minutes. Set params.intervalMinutes (default: 5).', + evaluate: (ctx: GuardContext): GuardResult => { + const intervalMinutes = (ctx.config.params?.intervalMinutes as number) || 5; + const windowMs = intervalMinutes * 60 * 1000; + const now = ctx.now.getTime(); + + const recentCount = getRecentCount(ctx.chatJid, ctx.senderJid, windowMs, now); + recordMessage(ctx.chatJid, ctx.senderJid, now); + + if (recentCount >= 1) { + return block('slow-mode', `Slow mode is active. You can send 1 message every ${intervalMinutes} minutes.`); + } + return pass; + }, +}; + +export const noSpamGuard: Guard = { + id: 'no-spam', + name: 'No Spam (Rate Limit)', + description: 'Block rapid-fire messages. Set params.maxMessages (default: 5) and params.windowSeconds (default: 10).', + evaluate: (ctx: GuardContext): GuardResult => { + const maxMessages = (ctx.config.params?.maxMessages as number) || 5; + const windowSeconds = (ctx.config.params?.windowSeconds as number) || 10; + const windowMs = windowSeconds * 1000; + const now = ctx.now.getTime(); + + const recentCount = getRecentCount(ctx.chatJid, ctx.senderJid, windowMs, now); + recordMessage(ctx.chatJid, ctx.senderJid, now); + + if (recentCount >= maxMessages) { + return block('no-spam', `You're sending messages too quickly. Max ${maxMessages} messages per ${windowSeconds} seconds.`); + } + return pass; + }, +}; + +export const approvedSendersGuard: Guard = { + id: 'approved-senders', + name: 'Approved Senders Only', + description: 'Only whitelisted senders can post. Set params.allowedJids as string array.', + evaluate: (ctx: GuardContext): GuardResult => { + const allowedJids = (ctx.config.params?.allowedJids as string[]) || []; + if (allowedJids.length === 0) return pass; + if (!allowedJids.includes(ctx.senderJid)) { + return block('approved-senders', 'You are not on the approved senders list for this group.'); + } + return pass; + }, +}; + +export const behavioralGuards: Guard[] = [ + quietHoursGuard, + slowModeGuard, + noSpamGuard, + approvedSendersGuard, +]; diff --git a/src/guards/content.ts b/src/guards/content.ts new file mode 100644 index 0000000..b461823 --- /dev/null +++ b/src/guards/content.ts @@ -0,0 +1,97 @@ +import { Guard, GuardContext, GuardResult } from './types.js'; + +const MEDIA_TYPES = new Set([ + 'imageMessage', + 'videoMessage', + 'audioMessage', + 'documentMessage', + 'documentWithCaptionMessage', + 'stickerMessage', +]); + +const pass: GuardResult = { blocked: false }; + +function block(guardId: string, reason: string): GuardResult { + return { blocked: true, guardId, reason }; +} + +export const textOnlyGuard: Guard = { + id: 'text-only', + name: 'Text Only', + description: 'Only text messages allowed — blocks media, stickers, documents, etc.', + evaluate: (ctx: GuardContext): GuardResult => { + const ct = ctx.contentType; + if (!ct) return pass; + if (ct === 'conversation' || ct === 'extendedTextMessage') return pass; + return block('text-only', 'Only text messages are allowed in this group.'); + }, +}; + +export const videoOnlyGuard: Guard = { + id: 'video-only', + name: 'Video Only', + description: 'Only video messages allowed.', + evaluate: (ctx: GuardContext): GuardResult => { + const ct = ctx.contentType; + if (!ct) return pass; + if (ct === 'videoMessage') return pass; + return block('video-only', 'Only video messages are allowed in this group.'); + }, +}; + +export const voiceOnlyGuard: Guard = { + id: 'voice-only', + name: 'Voice Only', + description: 'Only voice notes allowed.', + evaluate: (ctx: GuardContext): GuardResult => { + const ct = ctx.contentType; + if (!ct) return pass; + if (ct === 'audioMessage' && ctx.msg.message?.audioMessage?.ptt) return pass; + return block('voice-only', 'Only voice notes are allowed in this group.'); + }, +}; + +export const mediaOnlyGuard: Guard = { + id: 'media-only', + name: 'Media Only', + description: 'Only media messages (images, videos, audio, documents) allowed — blocks text.', + evaluate: (ctx: GuardContext): GuardResult => { + const ct = ctx.contentType; + if (!ct) return pass; + if (MEDIA_TYPES.has(ct)) return pass; + return block('media-only', 'Only media messages are allowed in this group.'); + }, +}; + +export const noStickersGuard: Guard = { + id: 'no-stickers', + name: 'No Stickers', + description: 'Block sticker messages.', + evaluate: (ctx: GuardContext): GuardResult => { + if (ctx.contentType === 'stickerMessage') { + return block('no-stickers', 'Stickers are not allowed in this group.'); + } + return pass; + }, +}; + +export const noImagesGuard: Guard = { + id: 'no-images', + name: 'No Images', + description: 'Block image messages.', + evaluate: (ctx: GuardContext): GuardResult => { + if (ctx.contentType === 'imageMessage') { + return block('no-images', 'Images are not allowed in this group.'); + } + return pass; + }, +}; + +export const contentGuards: Guard[] = [ + textOnlyGuard, + videoOnlyGuard, + voiceOnlyGuard, + mediaOnlyGuard, + noStickersGuard, + noImagesGuard, +]; diff --git a/src/guards/index.ts b/src/guards/index.ts new file mode 100644 index 0000000..d2d0b5b --- /dev/null +++ b/src/guards/index.ts @@ -0,0 +1,110 @@ +import { Guard, GuardContext, GuardResult, GroupGuardConfig, ModerationConfig, DEFAULT_MODERATION_CONFIG } from './types.js'; +import { contentGuards } from './content.js'; +import { propertyGuards } from './property.js'; +import { behavioralGuards } from './behavioral.js'; +import { keywordGuards } from './keyword.js'; +import { proto } from '@whiskeysockets/baileys'; + +// Registry of all available guards +const guardRegistry = new Map(); + +// Register all built-in guards +for (const guard of [...contentGuards, ...propertyGuards, ...behavioralGuards, ...keywordGuards]) { + guardRegistry.set(guard.id, guard); +} + +/** + * Get a guard by ID. + */ +export function getGuard(id: string): Guard | undefined { + return guardRegistry.get(id); +} + +/** + * List all available guards with their metadata. + */ +export function listGuards(): Array<{ id: string; name: string; description: string }> { + return Array.from(guardRegistry.values()).map((g) => ({ + id: g.id, + name: g.name, + description: g.description, + })); +} + +/** + * Extract the Baileys content type from a message. + */ +function getContentType(msg: proto.IWebMessageInfo): string | undefined { + if (!msg.message) return undefined; + // Baileys message types are keys on the message object + const keys = Object.keys(msg.message).filter( + (k) => k !== 'messageContextInfo' && k !== 'senderKeyDistributionMessage', + ); + return keys[0]; +} + +/** + * Extract text content from any message type. + */ +function extractTextContent(msg: proto.IWebMessageInfo): string { + const m = msg.message; + if (!m) return ''; + return ( + m.conversation || + m.extendedTextMessage?.text || + m.imageMessage?.caption || + m.videoMessage?.caption || + '' + ); +} + +/** + * Evaluate all enabled guards for a message. + * Returns the first blocking result, or a pass result. + */ +export function evaluateGuards( + msg: proto.IWebMessageInfo, + chatJid: string, + senderJid: string, + guardConfigs: GroupGuardConfig[], + moderationConfig: ModerationConfig, + isAdmin: boolean, +): GuardResult { + // Admin exemption + if (moderationConfig.adminExempt && isAdmin) { + return { blocked: false }; + } + + const contentType = getContentType(msg); + const textContent = extractTextContent(msg); + const now = new Date(); + + for (const config of guardConfigs) { + if (!config.enabled) continue; + + const guard = guardRegistry.get(config.guardId); + if (!guard) continue; + + const ctx: GuardContext = { + msg, + chatJid, + senderJid, + contentType, + textContent, + config, + isAdmin, + now, + }; + + const result = guard.evaluate(ctx); + if (result.blocked) { + return result; + } + } + + return { blocked: false }; +} + +// Re-export types +export type { Guard, GuardResult, GuardContext, GroupGuardConfig, ModerationConfig } from './types.js'; +export { DEFAULT_MODERATION_CONFIG } from './types.js'; diff --git a/src/guards/keyword.ts b/src/guards/keyword.ts new file mode 100644 index 0000000..44d1d38 --- /dev/null +++ b/src/guards/keyword.ts @@ -0,0 +1,60 @@ +import { Guard, GuardContext, GuardResult } from './types.js'; + +const pass: GuardResult = { blocked: false }; + +function block(guardId: string, reason: string): GuardResult { + return { blocked: true, guardId, reason }; +} + +// Cache compiled regexes per group to avoid recompiling on every message +const regexCache = new Map(); + +function getCompiledPatterns(config: { params?: Record }, chatJid: string): RegExp[] { + const patterns = (config.params?.patterns as string[]) || []; + const keywords = (config.params?.keywords as string[]) || []; + + // Simple cache key — invalidates if list changes length + const cacheKey = `${chatJid}:${patterns.length}:${keywords.length}`; + const cached = regexCache.get(cacheKey); + if (cached) return cached; + + const compiled: RegExp[] = []; + + for (const pattern of patterns) { + try { + compiled.push(new RegExp(pattern, 'i')); + } catch { + // Skip invalid regex patterns silently + } + } + + for (const keyword of keywords) { + // Escape special regex chars and match as whole word + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + compiled.push(new RegExp(`\\b${escaped}\\b`, 'i')); + } + + regexCache.set(cacheKey, compiled); + return compiled; +} + +export const keywordFilterGuard: Guard = { + id: 'keyword-filter', + name: 'Keyword Filter', + description: 'Block messages matching keyword/regex patterns. Set params.keywords (string[]) and/or params.patterns (regex string[]).', + evaluate: (ctx: GuardContext): GuardResult => { + if (!ctx.textContent) return pass; + + const compiled = getCompiledPatterns(ctx.config, ctx.chatJid); + if (compiled.length === 0) return pass; + + for (const regex of compiled) { + if (regex.test(ctx.textContent)) { + return block('keyword-filter', 'Your message was blocked by a content filter.'); + } + } + return pass; + }, +}; + +export const keywordGuards: Guard[] = [keywordFilterGuard]; diff --git a/src/guards/property.ts b/src/guards/property.ts new file mode 100644 index 0000000..4329f29 --- /dev/null +++ b/src/guards/property.ts @@ -0,0 +1,68 @@ +import { Guard, GuardContext, GuardResult } from './types.js'; + +const pass: GuardResult = { blocked: false }; + +function block(guardId: string, reason: string): GuardResult { + return { blocked: true, guardId, reason }; +} + +const URL_PATTERN = /https?:\/\/\S+|www\.\S+|\S+\.(com|org|net|io|co|me|info|xyz)\b/i; + +export const noLinksGuard: Guard = { + id: 'no-links', + name: 'No Links', + description: 'Block messages containing URLs.', + evaluate: (ctx: GuardContext): GuardResult => { + if (ctx.textContent && URL_PATTERN.test(ctx.textContent)) { + return block('no-links', 'Links are not allowed in this group.'); + } + const matchedText = ctx.msg.message?.extendedTextMessage?.matchedText; + if (matchedText) { + return block('no-links', 'Links are not allowed in this group.'); + } + return pass; + }, +}; + +export const noForwardedGuard: Guard = { + id: 'no-forwarded', + name: 'No Forwarded Messages', + description: 'Block forwarded messages.', + evaluate: (ctx: GuardContext): GuardResult => { + const msg = ctx.msg.message; + if (!msg) return pass; + + const contextInfo = + msg.extendedTextMessage?.contextInfo || + msg.imageMessage?.contextInfo || + msg.videoMessage?.contextInfo || + msg.audioMessage?.contextInfo || + msg.documentMessage?.contextInfo || + msg.stickerMessage?.contextInfo; + + if (contextInfo?.isForwarded) { + return block('no-forwarded', 'Forwarded messages are not allowed in this group.'); + } + return pass; + }, +}; + +export const maxTextLengthGuard: Guard = { + id: 'max-text-length', + name: 'Max Text Length', + description: 'Block text messages exceeding a character limit. Set params.maxLength (default: 2000).', + evaluate: (ctx: GuardContext): GuardResult => { + if (!ctx.textContent) return pass; + const maxLength = (ctx.config.params?.maxLength as number) || 2000; + if (ctx.textContent.length > maxLength) { + return block('max-text-length', `Messages over ${maxLength} characters are not allowed.`); + } + return pass; + }, +}; + +export const propertyGuards: Guard[] = [ + noLinksGuard, + noForwardedGuard, + maxTextLengthGuard, +]; diff --git a/src/guards/types.ts b/src/guards/types.ts new file mode 100644 index 0000000..0914f74 --- /dev/null +++ b/src/guards/types.ts @@ -0,0 +1,60 @@ +import { proto } from '@whiskeysockets/baileys'; + +/** + * Result of a guard evaluation. + * If blocked=true, the message should be deleted and the sender notified. + */ +export interface GuardResult { + blocked: boolean; + reason?: string; + guardId?: string; +} + +/** + * A guard evaluates a single message and decides whether to block it. + * Guards are pure functions — they don't delete messages or send DMs. + */ +export interface Guard { + id: string; + name: string; + description: string; + evaluate: (ctx: GuardContext) => GuardResult; +} + +/** + * Context passed to each guard for evaluation. + */ +export interface GuardContext { + msg: proto.IWebMessageInfo; + chatJid: string; + senderJid: string; + contentType: string | undefined; + textContent: string; + config: GroupGuardConfig; + isAdmin: boolean; + now: Date; +} + +/** + * Configuration for a single guard instance on a group. + */ +export interface GroupGuardConfig { + guardId: string; + enabled: boolean; + params?: Record; +} + +/** + * Top-level moderation configuration for a group. + */ +export interface ModerationConfig { + observationMode: boolean; + adminExempt: boolean; + dmCooldownSeconds: number; +} + +export const DEFAULT_MODERATION_CONFIG: ModerationConfig = { + observationMode: true, + adminExempt: true, + dmCooldownSeconds: 60, +}; diff --git a/src/index.ts b/src/index.ts index cf58c08..96a9567 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessa import { startSchedulerLoop } from './task-scheduler.js'; import { runContainerAgent, writeTasksSnapshot, writeGroupsSnapshot, AvailableGroup } from './container-runner.js'; import { loadJson, saveJson } from './utils.js'; +import { initModerator, moderateMessage, refreshAdminCache, updateAdminCache } from './moderator.js'; const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -163,7 +164,11 @@ async function processMessage(msg: NewMessage): Promise { if (response) { lastAgentTimestamp[msg.chat_jid] = msg.timestamp; - await sendMessage(msg.chat_jid, `${ASSISTANT_NAME}: ${response}`); + // Strip ... blocks — these are agent reasoning, not user-facing + const cleaned = response.replace(/[\s\S]*?<\/internal>/g, '').trim(); + if (cleaned) { + await sendMessage(msg.chat_jid, `${ASSISTANT_NAME}: ${cleaned}`); + } } } @@ -317,12 +322,14 @@ async function processTaskIpc( context_mode?: string; groupFolder?: string; chatJid?: string; - // For register_group + // For register_group / update_group_config jid?: string; name?: string; folder?: string; trigger?: string; containerConfig?: RegisteredGroup['containerConfig']; + guards?: RegisteredGroup['guards']; + moderationConfig?: RegisteredGroup['moderationConfig']; }, sourceGroup: string, // Verified identity from IPC directory isMain: boolean // Verified from directory path @@ -467,6 +474,27 @@ async function processTaskIpc( } break; + case 'update_group_config': + // Only main group can update guard/moderation configs + if (!isMain) { + logger.warn({ sourceGroup }, 'Unauthorized update_group_config attempt blocked'); + break; + } + if (data.jid && registeredGroups[data.jid]) { + const group = registeredGroups[data.jid]; + if (data.guards !== undefined) { + group.guards = data.guards as RegisteredGroup['guards']; + } + if (data.moderationConfig !== undefined) { + group.moderationConfig = data.moderationConfig as RegisteredGroup['moderationConfig']; + } + saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups); + logger.info({ jid: data.jid, guards: group.guards?.length || 0, observationMode: group.moderationConfig?.observationMode }, 'Group config updated via IPC'); + } else { + logger.warn({ jid: data.jid }, 'update_group_config: group not found'); + } + break; + default: logger.warn({ type: data.type }, 'Unknown IPC task type'); } @@ -482,7 +510,7 @@ async function connectWhatsApp(): Promise { auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, printQRInTerminal: false, logger, - browser: ['NanoClaw', 'Chrome', '1.0.0'] + browser: ['GroupGuard', 'Chrome', '1.0.0'] }); sock.ev.on('connection.update', (update) => { @@ -509,6 +537,19 @@ async function connectWhatsApp(): Promise { } } else if (connection === 'open') { logger.info('Connected to WhatsApp'); + + // Initialize moderation system + initModerator(sock); + + // Refresh admin caches for all registered groups + for (const chatJid of Object.keys(registeredGroups)) { + if (chatJid.endsWith('@g.us')) { + refreshAdminCache(chatJid).catch(err => + logger.warn({ chatJid, err }, 'Failed to refresh admin cache on startup') + ); + } + } + // Sync group metadata on startup (respects 24h cache) syncGroupMetadata().catch(err => logger.error({ err }, 'Initial group sync failed')); // Set up daily sync timer @@ -527,27 +568,53 @@ async function connectWhatsApp(): Promise { sock.ev.on('creds.update', saveCreds); - sock.ev.on('messages.upsert', ({ messages }) => { + sock.ev.on('messages.upsert', async ({ messages }) => { for (const msg of messages) { if (!msg.message) continue; const chatJid = msg.key.remoteJid; if (!chatJid || chatJid === 'status@broadcast') continue; + // Skip protocol messages (deletions, edits, reactions, etc.) — not real content + if (msg.message.protocolMessage || msg.message.reactionMessage || msg.message.editedMessage) continue; + const timestamp = new Date(Number(msg.messageTimestamp) * 1000).toISOString(); // Always store chat metadata for group discovery storeChatMetadata(chatJid, timestamp); - // Only store full message content for registered groups + // Only process full message content for registered groups if (registeredGroups[chatJid]) { + const group = registeredGroups[chatJid]; + + // Run moderation guards BEFORE storing the message + if (group.guards && group.guards.length > 0) { + const blocked = await moderateMessage( + msg, + chatJid, + group.guards, + group.moderationConfig, + ); + if (blocked) continue; // Skip storing blocked messages + } + storeMessage(msg, chatJid, msg.key.fromMe || false, msg.pushName || undefined); } } }); + + // Track group membership changes for admin cache updates + sock.ev.on('group-participants.update', async ({ id, participants, action }) => { + logger.info({ chatJid: id, action, count: participants.length }, 'Group participants updated'); + + // Refresh admin cache when participants change + if (registeredGroups[id]) { + await refreshAdminCache(id); + } + }); } async function startMessageLoop(): Promise { - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + logger.info(`GroupGuard running (trigger: @${ASSISTANT_NAME})`); while (true) { try { @@ -574,32 +641,41 @@ async function startMessageLoop(): Promise { } } -function ensureContainerSystemRunning(): void { +function ensureDockerRunning(): void { try { - execSync('container system status', { stdio: 'pipe' }); - logger.debug('Apple Container system already running'); + execSync('docker info', { stdio: 'pipe', timeout: 10000 }); + logger.debug('Docker is running'); } catch { - logger.info('Starting Apple Container system...'); - try { - execSync('container system start', { stdio: 'pipe', timeout: 30000 }); - logger.info('Apple Container system started'); - } catch (err) { - logger.error({ err }, 'Failed to start Apple Container system'); - console.error('\n╔════════════════════════════════════════════════════════════════╗'); - console.error('║ FATAL: Apple Container system failed to start ║'); - console.error('║ ║'); - console.error('║ Agents cannot run without Apple Container. To fix: ║'); - console.error('║ 1. Install from: https://github.com/apple/container/releases ║'); - console.error('║ 2. Run: container system start ║'); - console.error('║ 3. Restart NanoClaw ║'); - console.error('╚════════════════════════════════════════════════════════════════╝\n'); - throw new Error('Apple Container system is required but failed to start'); + console.error('\n╔════════════════════════════════════════════════════════╗'); + console.error('║ FATAL: Docker is not running ║'); + console.error('║ ║'); + console.error('║ Agents cannot run without Docker. To fix: ║'); + console.error('║ • macOS: Start Docker Desktop ║'); + console.error('║ • Linux: sudo systemctl start docker ║'); + console.error('╚════════════════════════════════════════════════════════╝\n'); + throw new Error('Docker is required but not running'); + } + + // Kill and clean up orphaned containers from previous runs + try { + const output = execSync( + 'docker ps --filter "name=nanoclaw-" --format "{{.Names}}"', + { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, + ); + const orphans = output.trim().split('\n').filter(Boolean); + for (const name of orphans) { + try { execSync(`docker stop ${name}`, { stdio: 'pipe' }); } catch { /* already stopped */ } + } + if (orphans.length > 0) { + logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); } + } catch (err) { + logger.warn({ err }, 'Failed to clean up orphaned containers'); } } async function main(): Promise { - ensureContainerSystemRunning(); + ensureDockerRunning(); initDatabase(); logger.info('Database initialized'); loadState(); diff --git a/src/moderator.ts b/src/moderator.ts new file mode 100644 index 0000000..a8af91d --- /dev/null +++ b/src/moderator.ts @@ -0,0 +1,158 @@ +/** + * GroupGuard Moderator + * + * Host-level message moderation that runs BEFORE messages reach the agent. + * Evaluates guards, deletes violations, DMs senders, and logs everything. + */ + +import { WASocket, proto } from '@whiskeysockets/baileys'; +import pino from 'pino'; + +import { evaluateGuards, GroupGuardConfig, ModerationConfig, DEFAULT_MODERATION_CONFIG } from './guards/index.js'; +import { logModeration } from './db.js'; +import { ASSISTANT_NAME } from './config.js'; + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + transport: { target: 'pino-pretty', options: { colorize: true } }, +}); + +let sock: WASocket; + +// Admin cache: chatJid -> Set +const adminCache = new Map>(); +// DM cooldown: senderJid -> last DM timestamp (ms) +const dmCooldowns = new Map(); + +/** + * Initialize the moderator with a WhatsApp socket. + */ +export function initModerator(waSock: WASocket): void { + sock = waSock; +} + +/** + * Update the admin list for a group. + * Called when group metadata is fetched or participants change. + */ +export function updateAdminCache(chatJid: string, adminJids: string[]): void { + adminCache.set(chatJid, new Set(adminJids)); +} + +/** + * Check if a sender is an admin in a group. + */ +export function isAdmin(chatJid: string, senderJid: string): boolean { + const admins = adminCache.get(chatJid); + return admins?.has(senderJid) || false; +} + +/** + * Fetch and cache admin list for a group from WhatsApp. + */ +export async function refreshAdminCache(chatJid: string): Promise { + try { + const metadata = await sock.groupMetadata(chatJid); + const admins = metadata.participants + .filter((p) => p.admin === 'admin' || p.admin === 'superadmin') + .map((p) => p.id); + updateAdminCache(chatJid, admins); + logger.debug({ chatJid, adminCount: admins.length }, 'Admin cache refreshed'); + } catch (err) { + logger.warn({ chatJid, err }, 'Failed to refresh admin cache'); + } +} + +/** + * Core moderation function. + * Evaluates all guards for a message and enforces the result. + * + * Returns true if message was blocked, false if it passed. + */ +export async function moderateMessage( + msg: proto.IWebMessageInfo, + chatJid: string, + guardConfigs: GroupGuardConfig[], + moderationConfig: ModerationConfig | undefined, +): Promise { + if (!msg.key || !msg.message) return false; + // Don't moderate the bot's own outgoing responses (prefixed with assistant name) + // Note: fromMe is true for ALL messages from this WhatsApp account (including the owner), + // so we can't use fromMe alone — we check message content to identify bot responses. + if (msg.key.fromMe) { + const m = msg.message; + const text = m?.conversation || m?.extendedTextMessage?.text || ''; + if (text.startsWith(`${ASSISTANT_NAME}:`)) return false; + } + // Only moderate group messages + if (!chatJid.endsWith('@g.us')) return false; + // No guards configured for this group + if (!guardConfigs || guardConfigs.length === 0) return false; + + const config = moderationConfig || DEFAULT_MODERATION_CONFIG; + const senderJid = msg.key.participant || msg.key.remoteJid || ''; + const senderIsAdmin = isAdmin(chatJid, senderJid); + + const result = evaluateGuards(msg, chatJid, senderJid, guardConfigs, config, senderIsAdmin); + + if (!result.blocked) return false; + + const guardId = result.guardId || 'unknown'; + const reason = result.reason || 'Message blocked by group rules.'; + + // Log the violation (always, regardless of observation mode) + logModeration({ + chat_jid: chatJid, + sender_jid: senderJid, + guard_id: guardId, + action: config.observationMode ? 'logged' : 'deleted', + reason, + message_id: msg.key.id || '', + timestamp: new Date().toISOString(), + }); + + if (config.observationMode) { + logger.info( + { chatJid, senderJid, guardId, reason }, + 'Guard violation detected (observation mode — not enforcing)', + ); + return false; // Don't block in observation mode + } + + // Enforce: delete the message + try { + await sock.sendMessage(chatJid, { delete: msg.key }); + logger.info({ chatJid, senderJid, guardId }, 'Message deleted by guard'); + } catch (err) { + logger.error({ chatJid, senderJid, guardId, err }, 'Failed to delete message'); + } + + // DM the sender with reason (with cooldown) + await dmSender(senderJid, reason, config.dmCooldownSeconds); + + return true; +} + +/** + * DM the sender with the violation reason (with cooldown to prevent spam). + */ +async function dmSender(senderJid: string, reason: string, cooldownSeconds: number): Promise { + const now = Date.now(); + const lastDm = dmCooldowns.get(senderJid) || 0; + + if (now - lastDm < cooldownSeconds * 1000) { + logger.debug({ senderJid }, 'DM cooldown active, skipping'); + return; + } + + try { + await sock.sendMessage(senderJid, { + text: `${ASSISTANT_NAME}: ${reason}`, + }); + dmCooldowns.set(senderJid, now); + logger.debug({ senderJid }, 'Violation DM sent'); + } catch (err) { + logger.warn({ senderJid, err }, 'Failed to DM sender'); + } +} + diff --git a/src/types.ts b/src/types.ts index de42c9c..cf84013 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,8 @@ export interface RegisteredGroup { trigger: string; added_at: string; containerConfig?: ContainerConfig; + guards?: Array<{ guardId: string; enabled: boolean; params?: Record }>; + moderationConfig?: { observationMode: boolean; adminExempt: boolean; dmCooldownSeconds: number }; } export interface Session { diff --git a/src/whatsapp-auth.ts b/src/whatsapp-auth.ts index a075d2a..5833567 100644 --- a/src/whatsapp-auth.ts +++ b/src/whatsapp-auth.ts @@ -2,9 +2,12 @@ * WhatsApp Authentication Script * * Run this during setup to authenticate with WhatsApp. - * Displays QR code, waits for scan, saves credentials, then exits. + * Uses pairing code by default (enter code on your phone). + * Pass --qr flag to use QR code scanning instead. * - * Usage: npx tsx src/whatsapp-auth.ts + * Usage: + * npx tsx src/whatsapp-auth.ts # pairing code (default) + * npx tsx src/whatsapp-auth.ts --qr # QR code */ import makeWASocket, { @@ -15,14 +18,28 @@ import makeWASocket, { import pino from 'pino'; import qrcode from 'qrcode-terminal'; import fs from 'fs'; -import path from 'path'; +import readline from 'readline'; const AUTH_DIR = './store/auth'; +const useQR = process.argv.includes('--qr'); const logger = pino({ - level: 'warn', // Quiet logging - only show errors + level: 'warn', }); +function askQuestion(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + async function authenticate(): Promise { fs.mkdirSync(AUTH_DIR, { recursive: true }); @@ -43,13 +60,44 @@ async function authenticate(): Promise { }, printQRInTerminal: false, logger, - browser: ['NanoClaw', 'Chrome', '1.0.0'], + browser: ['GroupGuard', 'Chrome', '1.0.0'], }); + // Request pairing code if not using QR mode + if (!useQR && !state.creds.registered) { + const phoneNumber = await askQuestion( + 'Enter your phone number (with country code, no + or spaces, e.g. 14155551234): ', + ); + + if (!/^\d{7,15}$/.test(phoneNumber)) { + console.error('✗ Invalid phone number. Use digits only with country code (e.g. 14155551234)'); + process.exit(1); + } + + // Small delay to let the socket connect before requesting pairing code + await new Promise((resolve) => setTimeout(resolve, 3000)); + + try { + const code = await sock.requestPairingCode(phoneNumber); + console.log(`\nYour pairing code: ${code}\n`); + console.log('On your phone:'); + console.log(' 1. Open WhatsApp'); + console.log(' 2. Tap Settings → Linked Devices → Link a Device'); + console.log(` 3. Tap "Link with phone number instead"`); + console.log(` 4. Enter the code: ${code}\n`); + console.log('Waiting for confirmation...'); + } catch (err) { + console.error('✗ Failed to request pairing code:', (err as Error).message); + console.log(' Try again, or use --qr flag for QR code authentication.'); + process.exit(1); + } + } + sock.ev.on('connection.update', (update) => { const { connection, lastDisconnect, qr } = update; - if (qr) { + // Only show QR code in QR mode + if (qr && useQR) { console.log('Scan this QR code with WhatsApp:\n'); console.log(' 1. Open WhatsApp on your phone'); console.log(' 2. Tap Settings → Linked Devices → Link a Device'); @@ -72,9 +120,8 @@ async function authenticate(): Promise { if (connection === 'open') { console.log('\n✓ Successfully authenticated with WhatsApp!'); console.log(' Credentials saved to store/auth/'); - console.log(' You can now start the NanoClaw service.\n'); + console.log(' You can now start GroupGuard.\n'); - // Give it a moment to save credentials, then exit setTimeout(() => process.exit(0), 1000); } }); diff --git a/systemd/groupguard.service b/systemd/groupguard.service new file mode 100644 index 0000000..68a2350 --- /dev/null +++ b/systemd/groupguard.service @@ -0,0 +1,20 @@ +[Unit] +Description=GroupGuard - WhatsApp Claude Assistant +After=network.target docker.service +Requires=docker.service + +[Service] +Type=simple +ExecStart=/usr/bin/node dist/index.js +WorkingDirectory={{PROJECT_ROOT}} +Environment=NODE_ENV=production +Environment=PATH=/usr/local/bin:/usr/bin:/bin +User={{USER}} +Restart=always +RestartSec=5 + +StandardOutput=append:{{PROJECT_ROOT}}/logs/groupguard.log +StandardError=append:{{PROJECT_ROOT}}/logs/groupguard.error.log + +[Install] +WantedBy=multi-user.target