From 5555ab386a56e5e7c76c5f170771c538953f9e17 Mon Sep 17 00:00:00 2001 From: Tom Granot Date: Thu, 12 Feb 2026 11:32:27 +0100 Subject: [PATCH 1/2] chore: sanitize repo for public release - Replace real WhatsApp JIDs with placeholders - Remove Qwibit business info from testing group - Update CODEOWNERS to TomGranot - Update LICENSE copyright to Tom Granot - Replace all "Andy" references with "GroupGuard" - Fix launchd plist default assistant name Co-Authored-By: Claude Opus 4.6 --- .claude/skills/add-gmail/SKILL.md | 12 ++++---- .claude/skills/customize/SKILL.md | 2 +- .claude/skills/setup/SKILL.md | 6 ++-- .github/CODEOWNERS | 8 ++--- LICENSE | 2 +- container/agent-runner/src/ipc-mcp.ts | 4 +-- docs/REQUIREMENTS.md | 6 ++-- docs/SPEC.md | 44 +++++++++++++-------------- groups/main/CLAUDE.md | 10 +++--- groups/nanoclaw-testing/CLAUDE.md | 20 ++---------- launchd/com.nanoclaw.plist | 2 +- 11 files changed, 50 insertions(+), 66 deletions(-) diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md index db6e3e1..cb7f3c1 100644 --- a/.claude/skills/add-gmail/SKILL.md +++ b/.claude/skills/add-gmail/SKILL.md @@ -18,7 +18,7 @@ Ask the user: > > **Option 1: Tool Mode** > - Agent can read and send emails when you ask it to -> - Triggered only from WhatsApp (e.g., "@Andy check my email" or "@Andy send an email to...") +> - Triggered only from WhatsApp (e.g., "@GroupGuard check my email" or "@GroupGuard send an email to...") > - Simpler setup, no email polling > > **Option 2: Channel Mode** @@ -261,11 +261,11 @@ Tell the user: > Gmail integration is set up! Test it by sending this message in your WhatsApp main channel: > -> `@Andy check my recent emails` +> `@GroupGuard check my recent emails` > > Or: > -> `@Andy list my Gmail labels` +> `@GroupGuard list my Gmail labels` Watch the logs for any errors: @@ -295,7 +295,7 @@ Ask the user: > - Uses Gmail's plus-addressing feature > > **Option C: Subject Prefix** -> - Emails with a subject starting with a keyword (e.g., "[Andy]") +> - Emails with a subject starting with a keyword (e.g., "[GroupGuard]") > - Anyone can trigger the agent by using the prefix Also ask: @@ -318,7 +318,7 @@ Store their choices for implementation. ### Step 1: Complete Tool Mode First -Complete all Tool Mode steps above before continuing. Verify Gmail tools work by having the user test `@Andy check my recent emails`. +Complete all Tool Mode steps above before continuing. Verify Gmail tools work by having the user test `@GroupGuard check my recent emails`. ### Step 2: Add Email Polling Configuration @@ -344,7 +344,7 @@ export const EMAIL_CHANNEL: EmailChannelConfig = { triggerValue: 'NanoClaw', // the label name, address pattern, or prefix contextMode: 'thread', pollIntervalMs: 60000, // Check every minute - replyPrefix: '[Andy] ' + replyPrefix: '[GroupGuard] ' }; ``` diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md index e01ec19..06964ae 100644 --- a/.claude/skills/customize/SKILL.md +++ b/.claude/skills/customize/SKILL.md @@ -100,7 +100,7 @@ launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist User: "Add Telegram as an input channel" -1. Ask: "Should Telegram use the same @Andy trigger, or a different one?" +1. Ask: "Should Telegram use the same @GroupGuard trigger, or a different one?" 2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?" 3. Find Telegram MCP or library 4. Add connection handling in index.ts diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index e92ca34..0cfd21c 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -129,7 +129,7 @@ This step configures three things at once: the trigger word, the main channel ty ### 6a. Ask for trigger word Ask the user: -> What trigger word do you want to use? (default: `Andy`) +> What trigger word do you want to use? (default: `GroupGuard`) > > 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. @@ -227,8 +227,8 @@ 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 +If the user chose a name other than `GroupGuard`, also update: +1. `groups/global/CLAUDE.md` - Change "# GroupGuard" and "You are GroupGuard" to the new name 2. `groups/main/CLAUDE.md` - Same changes at the top Ensure the groups folder exists: diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7ef4de2..b6281af 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,8 @@ # Core code - maintainer only -/src/ @gavrielc -/container/ @gavrielc -/package.json @gavrielc -/package-lock.json @gavrielc +/src/ @TomGranot +/container/ @TomGranot +/package.json @TomGranot +/package-lock.json @TomGranot # Skills - open to contributors /.claude/skills/ diff --git a/LICENSE b/LICENSE index e5b4bee..a7f7dee 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Gavriel +Copyright (c) 2026 Tom Granot Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/container/agent-runner/src/ipc-mcp.ts b/container/agent-runner/src/ipc-mcp.ts index d6c1fc8..e35fac3 100644 --- a/container/agent-runner/src/ipc-mcp.ts +++ b/container/agent-runner/src/ipc-mcp.ts @@ -284,10 +284,10 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): Use available_groups.json to find the JID for a group. The folder name should be lowercase with hyphens (e.g., "family-chat").`, { - jid: z.string().describe('The WhatsApp JID (e.g., "120363336345536173@g.us")'), + jid: z.string().describe('The WhatsApp JID (e.g., "1234567890-1234567890@g.us")'), name: z.string().describe('Display name for the group'), folder: z.string().describe('Folder name for group files (lowercase, hyphens, e.g., "family-chat")'), - trigger: z.string().describe('Trigger word (e.g., "@Andy")') + trigger: z.string().describe('Trigger word (e.g., "@GroupGuard")') }, async (args) => { if (!isMain) { diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 40dc234..a06c484 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -84,7 +84,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ### Message Routing - A router listens to WhatsApp and routes messages based on configuration - Only messages from registered groups are processed -- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var +- Trigger: `@GroupGuard` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Unregistered groups are ignored completely ### Memory System @@ -179,8 +179,8 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. These are the creator's settings, stored here for reference: -- **Trigger**: `@Andy` (case insensitive) -- **Response prefix**: `Andy:` +- **Trigger**: `@GroupGuard` (case insensitive) +- **Response prefix**: `GroupGuard:` - **Persona**: Default Claude (no custom personality) - **Main channel**: Self-chat (messaging yourself in WhatsApp) diff --git a/docs/SPEC.md b/docs/SPEC.md index 8da5935..44b25b9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -169,7 +169,7 @@ Configuration constants are in `src/config.ts`: ```typescript 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; @@ -198,7 +198,7 @@ Groups can have additional directories mounted via `containerConfig` in `data/re "1234567890@g.us": { "name": "Dev Team", "folder": "dev-team", - "trigger": "@Andy", + "trigger": "@GroupGuard", "added_at": "2026-01-31T12:00:00Z", "containerConfig": { "additionalMounts": [ @@ -357,10 +357,10 @@ Sessions enable conversation continuity - Claude remembers what you talked about ### Trigger Word Matching -Messages must start with the trigger pattern (default: `@Andy`): -- `@Andy what's the weather?` → ✅ Triggers Claude -- `@andy help me` → ✅ Triggers (case insensitive) -- `Hey @Andy` → ❌ Ignored (trigger not at start) +Messages must start with the trigger pattern (default: `@GroupGuard`): +- `@GroupGuard what's the weather?` → ✅ Triggers Claude +- `@groupguard help me` → ✅ Triggers (case insensitive) +- `Hey @GroupGuard` → ❌ Ignored (trigger not at start) - `What's up?` → ❌ Ignored (no trigger) ### Conversation Catch-Up @@ -370,7 +370,7 @@ When a triggered message arrives, the agent receives all messages since its last ``` [Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight? [Jan 31 2:33 PM] Sarah: sounds good to me -[Jan 31 2:35 PM] John: @Andy what toppings do you recommend? +[Jan 31 2:35 PM] John: @GroupGuard what toppings do you recommend? ``` This allows the agent to understand the conversation context even if it wasn't mentioned in every message. @@ -383,16 +383,16 @@ This allows the agent to understand the conversation context even if it wasn't m | Command | Example | Effect | |---------|---------|--------| -| `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude | +| `@Assistant [message]` | `@GroupGuard what's the weather?` | Talk to Claude | ### Commands Available in Main Channel Only | Command | Example | Effect | |---------|---------|--------| -| `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | Register a new group | -| `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | Unregister a group | -| `@Assistant list groups` | `@Andy list groups` | Show registered groups | -| `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | Add to global memory | +| `@Assistant add group "Name"` | `@GroupGuard add group "Family Chat"` | Register a new group | +| `@Assistant remove group "Name"` | `@GroupGuard remove group "Work Team"` | Unregister a group | +| `@Assistant list groups` | `@GroupGuard list groups` | Show registered groups | +| `@Assistant remember [fact]` | `@GroupGuard remember I prefer dark mode` | Add to global memory | --- @@ -418,7 +418,7 @@ NanoClaw has a built-in scheduler that runs tasks as full agents in their group' ### Creating a Task ``` -User: @Andy remind me every Monday at 9am to review the weekly metrics +User: @GroupGuard remind me every Monday at 9am to review the weekly metrics Claude: [calls mcp__nanoclaw__schedule_task] { @@ -433,7 +433,7 @@ Claude: Done! I'll remind you every Monday at 9am. ### One-Time Tasks ``` -User: @Andy at 5pm today, send me a summary of today's emails +User: @GroupGuard at 5pm today, send me a summary of today's emails Claude: [calls mcp__nanoclaw__schedule_task] { @@ -446,14 +446,14 @@ Claude: [calls mcp__nanoclaw__schedule_task] ### Managing Tasks From any group: -- `@Andy list my scheduled tasks` - View tasks for this group -- `@Andy pause task [id]` - Pause a task -- `@Andy resume task [id]` - Resume a paused task -- `@Andy cancel task [id]` - Delete a task +- `@GroupGuard list my scheduled tasks` - View tasks for this group +- `@GroupGuard pause task [id]` - Pause a task +- `@GroupGuard resume task [id]` - Resume a paused task +- `@GroupGuard cancel task [id]` - Delete a task From main channel: -- `@Andy list all tasks` - View tasks from all groups -- `@Andy schedule task for "Family Chat": [prompt]` - Schedule for another group +- `@GroupGuard list all tasks` - View tasks from all groups +- `@GroupGuard schedule task for "Family Chat": [prompt]` - Schedule for another group --- @@ -520,7 +520,7 @@ When NanoClaw starts, it: HOME {{HOME}} ASSISTANT_NAME - Andy + GroupGuard StandardOutPath {{PROJECT_ROOT}}/logs/nanoclaw.log @@ -608,7 +608,7 @@ chmod 700 groups/ | 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/` | | "QR code expired" | WhatsApp session expired | Delete store/auth/ and restart | -| "No groups registered" | Haven't added groups | Use `@Andy add group "Name"` in main | +| "No groups registered" | Haven't added groups | Use `@GroupGuard add group "Name"` in main | ### Log Location diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 21ce491..d9d9ef8 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -64,7 +64,7 @@ Available groups are provided in `/workspace/ipc/available_groups.json`: { "groups": [ { - "jid": "120363336345536173@g.us", + "jid": "1234567890-1234567890@g.us", "name": "Family Chat", "lastActivity": "2026-01-31T12:00:00.000Z", "isRegistered": false @@ -170,7 +170,7 @@ Query the database or read the source to list guards. Here's the full list: cat > /workspace/ipc/tasks/config_$(date +%s%N).json << 'EOF' { "type": "update_group_config", - "jid": "120363422834835417@g.us", + "jid": "1234567890-1234567890@g.us", "guards": [ { "guardId": "no-spam", "enabled": true }, { "guardId": "no-links", "enabled": true }, @@ -192,7 +192,7 @@ To update only moderation config (without changing guards), omit the `guards` fi cat > /workspace/ipc/tasks/config_$(date +%s%N).json << 'EOF' { "type": "update_group_config", - "jid": "120363422834835417@g.us", + "jid": "1234567890-1234567890@g.us", "moderationConfig": { "observationMode": true, "adminExempt": true, @@ -208,7 +208,7 @@ EOF sqlite3 /workspace/project/store/messages.db " SELECT timestamp, sender_jid, guard_id, action, reason FROM moderation_log - WHERE chat_jid = '120363336345536173@g.us' + WHERE chat_jid = '1234567890-1234567890@g.us' ORDER BY timestamp DESC LIMIT 20; " @@ -220,7 +220,7 @@ sqlite3 /workspace/project/store/messages.db " sqlite3 /workspace/project/store/messages.db " SELECT guard_id, COUNT(*) as violations FROM moderation_log - WHERE chat_jid = '120363336345536173@g.us' + WHERE chat_jid = '1234567890-1234567890@g.us' GROUP BY guard_id ORDER BY violations DESC; " diff --git a/groups/nanoclaw-testing/CLAUDE.md b/groups/nanoclaw-testing/CLAUDE.md index 0413efa..c0dba15 100644 --- a/groups/nanoclaw-testing/CLAUDE.md +++ b/groups/nanoclaw-testing/CLAUDE.md @@ -1,6 +1,6 @@ -# NanoClaw Testing +# Testing Group -This group is used for testing NanoClaw features and functionality. +This group is used for testing GroupGuard features and functionality. ## What You Can Do @@ -11,22 +11,6 @@ This group is used for testing NanoClaw features and functionality. - Schedule tasks to run later or on a recurring basis - Send messages back to the chat -## 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) - ## WhatsApp Formatting Do NOT use markdown headings (##) in WhatsApp messages. Only use: diff --git a/launchd/com.nanoclaw.plist b/launchd/com.nanoclaw.plist index 82bef0a..02bcffe 100644 --- a/launchd/com.nanoclaw.plist +++ b/launchd/com.nanoclaw.plist @@ -22,7 +22,7 @@ HOME {{HOME}} ASSISTANT_NAME - Andy + GroupGuard StandardOutPath {{PROJECT_ROOT}}/logs/nanoclaw.log From f87f176afab36346e6af10b4b38fb20dcff82c03 Mon Sep 17 00:00:00 2001 From: Tom Granot Date: Thu, 12 Feb 2026 20:28:38 +0100 Subject: [PATCH 2/2] feat: revert multi-tenancy, add Apple Container support, rewrite README Strip all tenant code (tenant.ts, admin-cli.ts, tenant-control/) and restore single-user isMain-based auth. Keep API key security (secrets via stdin, PreToolUse sanitize hook) and container hardening flags. Add runtime detection: macOS defaults to Apple Containers if available, falls back to Docker. Linux always uses Docker. CONTAINER_RUNTIME env var overrides auto-detection. Updated build.sh, setup.sh, and macos-networking.sh with idempotency and --non-interactive support. Rewrite README with clean structure: guards table, deployment guide (macOS + Hetzner), architecture overview. Co-Authored-By: Claude Opus 4.6 --- README.md | 158 +++++++++---------- container/Dockerfile | 4 +- container/agent-runner/src/index.ts | 54 ++++++- container/agent-runner/src/ipc-mcp.ts | 75 +++++++-- container/build.sh | 36 ++++- scripts/health-check.sh | 157 +++++++++++++++++++ scripts/macos-networking.sh | 77 ++++++++-- setup.sh | 75 ++++++--- src/container-runner.ts | 209 ++++++++++++++++++-------- src/index.ts | 98 ++++++------ src/task-scheduler.ts | 5 +- 11 files changed, 694 insertions(+), 254 deletions(-) create mode 100755 scripts/health-check.sh diff --git a/README.md b/README.md index f80c1d8..990ff9c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@ # GroupGuard -WhatsApp group moderation powered by Claude. Automated content filtering, spam prevention, and natural-language admin controls — all running in isolated containers. +WhatsApp group moderation powered by Claude. -Built on [NanoClaw](https://github.com/gavrielc/nanoclaw). +## Quick Start + +```bash +git clone git@github.com:TomGranot/groupguard.git +cd groupguard +./setup.sh +``` + +**Requirements:** Node.js 20+, macOS 26+ (Apple Containers) or Docker, [Claude Code](https://claude.ai/download) ## What It Does -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. +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. ``` -@GroupGuard enable no-spam and no-links for Family Chat -@GroupGuard set observation mode for Work Team (log violations but don't delete) +@GroupGuard enable no-spam and no-links for this group +@GroupGuard set observation mode (log violations but don't delete) @GroupGuard show moderation stats for the last week -@GroupGuard disable quiet-hours for the Main group +@GroupGuard add a keyword filter blocking "crypto" and "forex" ``` -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. +Beyond moderation, it's a full Claude assistant — it can answer questions, search the web, schedule tasks, and manage files. The moderation runs silently in the background. ## Guards @@ -47,7 +55,7 @@ Beyond moderation, it's a full Claude assistant — it can answer questions, sea | `quiet-hours` | Block during certain hours | `startHour` (22), `endHour` (7) | | `approved-senders` | Whitelist-only mode | `allowedJids` | -Guards run on the host process, not inside the container — enforcement is instant. Messages blocked by guards never reach the database. +Guards run on the host process, not inside the container — enforcement is instant. ## How Moderation Works @@ -73,7 +81,7 @@ Observation mode? --> Yes: log violation, store message anyway Delete message + DM sender with reason + log violation ``` -Each group has independent guard configurations and a moderation config: +Each group has independent guard configurations: ```json { @@ -89,53 +97,12 @@ Each group has independent guard configurations and a moderation config: } ``` -- **Observation mode**: Log violations without deleting — useful for testing rules before enforcing +- **Observation mode**: Log violations without deleting — useful for testing rules - **Admin exempt**: Group admins bypass all guards -- **DM cooldown**: Prevent notification spam (one DM per user per 60s) +- **DM cooldown**: One notification per user per 60s to prevent spam All violations are logged to SQLite with timestamp, sender, guard ID, action, and reason. -## Quick Start - -```bash -git clone git@github.com:TomGranot/groupguard.git -cd groupguard -./setup.sh -``` - -Or use Claude Code for guided setup: run `claude` then `/setup`. - -**Requirements:** Node.js 20+, Docker, [Claude Code](https://claude.ai/download) (for API key) - -## Architecture - -``` -WhatsApp (baileys) --> Guard filter --> SQLite --> Polling loop --> Docker (Claude Agent SDK) --> Response -``` - -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. - -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 - ## Usage Talk to your bot with the trigger word (default: `@GroupGuard`): @@ -143,54 +110,49 @@ Talk to your bot with the trigger word (default: `@GroupGuard`): ``` @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 +@GroupGuard schedule a daily summary at 9am +@GroupGuard what's the weather in Tel Aviv? ``` From the main channel, you have admin control over all groups: ``` @GroupGuard list all groups and their guard configs @GroupGuard enable observation mode for Work Team -@GroupGuard show moderation stats across all groups +@GroupGuard register the "Family Chat" group ``` ## Deploying to a Server -GroupGuard needs Docker to spawn agent containers, which rules out most managed platforms — you need a real VM. +GroupGuard needs a container runtime (Docker or Apple Containers) to spawn agent containers. -### Option 1: One-Click Deploy (exe.dev) — $20/month +### Local macOS (free) -The fastest path. [exe.dev](https://exe.dev) gives you a VM with Docker pre-installed and an AI agent that sets everything up. +Just run `./setup.sh`. It auto-detects Apple Containers (macOS 26+) or Docker Desktop and installs a launchd service that starts on boot. -After the VM is provisioned (~5 min), authenticate WhatsApp: +### Hetzner VPS ($4/mo, always-on) -```bash -ssh .exe.xyz -cd /opt/groupguard && npm run auth # scan QR code with your phone -sudo systemctl start groupguard # start the service -``` - -### Option 2: Budget VPS (Hetzner) — ~$4/month - -Best value. [Hetzner Cloud](https://www.hetzner.com/cloud/) with dedicated resources. +Best value for an always-on server. -1. Create a server: **CX22** (2 vCPU, 4 GB RAM, 40 GB disk), Docker CE app image, Ubuntu 24.04 +1. Create a [Hetzner Cloud](https://www.hetzner.com/cloud/) server: **CX22** (2 vCPU, 4 GB RAM), Docker CE app image, Ubuntu 24.04 2. SSH in and run: ```bash +# Install Node.js curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash source ~/.bashrc && nvm install 22 +# Clone and setup git clone git@github.com:TomGranot/groupguard.git /opt/groupguard cd /opt/groupguard echo 'ANTHROPIC_API_KEY=your-key-here' > .env ./setup.sh -npm run auth # scan QR code -sudo systemctl start groupguard # start the service +# Authenticate WhatsApp (scan QR code) +npm run auth +sudo systemctl start groupguard ``` -### Other Options +### Other VPS Options | Provider | Cost | Notes | |----------|------|-------| @@ -198,18 +160,35 @@ sudo systemctl start groupguard # start the service | **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` | -## Troubleshooting +### Updating -- **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` +```bash +cd /opt/groupguard +git pull +npm install +npm run build +./container/build.sh +sudo systemctl restart groupguard # or launchctl on macOS +``` -Run `/debug` in Claude Code for guided troubleshooting. +## Architecture + +``` +WhatsApp (baileys) --> Guard filter --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response +``` + +Single Node.js process. Moderation runs on the host for instant enforcement. Agent responses run in isolated containers with mounted directories. Per-group message queues. IPC via filesystem. + +| 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/container-runner.ts` | Spawns containers with runtime detection (Docker/Apple) | +| `src/task-scheduler.ts` | Runs scheduled tasks | +| `src/db.ts` | SQLite operations (messages, moderation logs, tasks) | +| `groups/*/CLAUDE.md` | Per-group memory (isolated) | ## Customizing @@ -217,14 +196,25 @@ The codebase is small enough to modify safely. Tell Claude Code what you want: - "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" +- "Add a daily moderation report sent to the admin group" Or run `/customize` for guided changes. -## Based On +## Troubleshooting -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. +- **Container runtime not running** — macOS: start Docker Desktop or ensure Apple Containers is available. 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 logs: `sqlite3 store/messages.db "SELECT * FROM moderation_log ORDER BY timestamp DESC LIMIT 10"` +- **Container networking on macOS** — Docker Desktop handles this automatically. For Apple Containers or colima/lima, run `sudo ./scripts/macos-networking.sh` + +Run `/debug` in Claude Code for guided troubleshooting. ## License MIT + +--- + +Built on [NanoClaw](https://github.com/gavrielc/nanoclaw). diff --git a/container/Dockerfile b/container/Dockerfile index 1cdff3c..470bc6a 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -54,8 +54,8 @@ 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 -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 +# Secrets are passed via stdin JSON, not via env files +RUN printf '#!/bin/bash\nset -e\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 RUN chown -R node:node /workspace diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2c7bb53..e77624f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -1,11 +1,11 @@ /** - * NanoClaw Agent Runner + * GroupGuard Agent Runner * Runs inside a container, receives config via stdin, outputs result to stdout */ import fs from 'fs'; import path from 'path'; -import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; import { createIpcMcp } from './ipc-mcp.js'; interface ContainerInput { @@ -15,6 +15,7 @@ interface ContainerInput { chatJid: string; isMain: boolean; isScheduledTask?: boolean; + secrets?: Record; } interface ContainerOutput { @@ -58,6 +59,42 @@ function log(message: string): void { console.error(`[agent-runner] ${message}`); } +// Env vars to strip from all Bash subprocesses — prevents agent from reading API keys +const SANITIZED_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +/** + * PreToolUse hook that sanitizes secret env vars from every Bash command. + * Prepends `unset VAR1 VAR2 2>/dev/null;` to the command so even `env`, + * `echo $VAR`, or `cat /proc/self/environ` won't reveal secrets. + */ +function createSanitizeBashHook(): HookCallback { + const unsetPrefix = `unset ${SANITIZED_VARS.join(' ')} 2>/dev/null; `; + + return async (input, _toolUseId, _context) => { + const preToolUse = input as PreToolUseHookInput; + + if (preToolUse.tool_name !== 'Bash') { + return {}; + } + + const toolInput = preToolUse.tool_input as { command?: string } | undefined; + if (!toolInput?.command) { + return {}; + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse' as const, + permissionDecision: 'allow' as const, + updatedInput: { + ...toolInput, + command: unsetPrefix + toolInput.command + } + } + }; + }; +} + function getSessionSummary(sessionId: string, transcriptPath: string): string | null { // sessions-index.json is in the same directory as the transcript const projectDir = path.dirname(transcriptPath); @@ -207,6 +244,16 @@ async function main(): Promise { const stdinData = await readStdin(); input = JSON.parse(stdinData); log(`Received input for group: ${input.groupFolder}`); + + // Set secrets in process memory (SDK needs them for auth) + // They stay in Node.js process.env but are stripped from Bash subprocesses by the hook + if (input.secrets) { + for (const [key, value] of Object.entries(input.secrets)) { + process.env[key] = value; + } + delete input.secrets; // Don't keep secrets in the input object + log('Secrets loaded into process environment'); + } } catch (err) { writeOutput({ status: 'error', @@ -219,7 +266,7 @@ async function main(): Promise { const ipcMcp = createIpcMcp({ chatJid: input.chatJid, groupFolder: input.groupFolder, - isMain: input.isMain + isMain: input.isMain, }); let result: string | null = null; @@ -252,6 +299,7 @@ async function main(): Promise { nanoclaw: ipcMcp }, hooks: { + PreToolUse: [{ hooks: [createSanitizeBashHook()] }], PreCompact: [{ hooks: [createPreCompactHook()] }] } } diff --git a/container/agent-runner/src/ipc-mcp.ts b/container/agent-runner/src/ipc-mcp.ts index e35fac3..a986e62 100644 --- a/container/agent-runner/src/ipc-mcp.ts +++ b/container/agent-runner/src/ipc-mcp.ts @@ -1,5 +1,5 @@ /** - * IPC-based MCP Server for NanoClaw + * IPC-based MCP Server for GroupGuard * Writes messages and tasks to files for the host process to pick up */ @@ -71,25 +71,25 @@ export function createIpcMcp(ctx: IpcMcpContext) { `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. CONTEXT MODE - Choose based on task type: -• "group" (recommended for most tasks): Task runs in the group's conversation context, with access to chat history and memory. Use for tasks that need context about ongoing discussions, user preferences, or previous interactions. -• "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. +- "group" (recommended for most tasks): Task runs in the group's conversation context, with access to chat history and memory. Use for tasks that need context about ongoing discussions, user preferences, or previous interactions. +- "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. If unsure which mode to use, ask the user. Examples: -- "Remind me about our discussion" → group (needs conversation context) -- "Check the weather every morning" → isolated (self-contained task) -- "Follow up on my request" → group (needs to know what was requested) -- "Generate a daily report" → isolated (just needs instructions in prompt) +- "Remind me about our discussion" -> group (needs conversation context) +- "Check the weather every morning" -> isolated (self-contained task) +- "Follow up on my request" -> group (needs to know what was requested) +- "Generate a daily report" -> isolated (just needs instructions in prompt) SCHEDULE VALUE FORMAT (all times are LOCAL timezone): -• cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) -• interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) -• once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, +- cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) +- interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) +- once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, { prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), - target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)') + target_group: z.string().optional().describe('Target group folder (main channel only, defaults to current group)') }, async (args) => { // Validate schedule_value before writing IPC @@ -120,7 +120,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): } } - // Non-main groups can only schedule for themselves + // Main channel can target any group; regular groups only schedule for themselves const targetGroup = isMain && args.target_group ? args.target_group : groupFolder; const data = { @@ -149,7 +149,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): // Reads from current_tasks.json which host keeps updated tool( 'list_tasks', - 'List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group\'s tasks.', + 'List all scheduled tasks. From main channel: shows all tasks. From other groups: shows only that group\'s tasks.', {}, async () => { const tasksFile = path.join(IPC_DIR, 'current_tasks.json'); @@ -280,7 +280,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): tool( 'register_group', - `Register a new WhatsApp group so the agent can respond to messages there. Main group only. + `Register a new WhatsApp group so the agent can respond to messages there. Main channel only. Use available_groups.json to find the JID for a group. The folder name should be lowercase with hyphens (e.g., "family-chat").`, { @@ -292,7 +292,7 @@ Use available_groups.json to find the JID for a group. The folder name should be async (args) => { if (!isMain) { return { - content: [{ type: 'text', text: 'Only the main group can register new groups.' }], + content: [{ type: 'text', text: 'Only the main channel can register new groups.' }], isError: true }; } @@ -315,7 +315,50 @@ Use available_groups.json to find the JID for a group. The folder name should be }] }; } - ) + ), + + tool( + 'update_group_config', + `Update guard or moderation configuration for a group. Main channel only.`, + { + jid: z.string().describe('The WhatsApp JID of the group to update'), + guards: z.array(z.object({ + guardId: z.string(), + enabled: z.boolean(), + params: z.record(z.string(), z.unknown()).optional() + })).optional().describe('Guard configurations'), + moderation_config: z.object({ + observationMode: z.boolean(), + adminExempt: z.boolean(), + dmCooldownSeconds: z.number() + }).optional().describe('Moderation settings') + }, + async (args) => { + if (!isMain) { + return { + content: [{ type: 'text', text: 'Only the main channel can update group configs.' }], + isError: true + }; + } + + const data: Record = { + type: 'update_group_config', + jid: args.jid, + timestamp: new Date().toISOString() + }; + if (args.guards) data.guards = args.guards; + if (args.moderation_config) data.moderationConfig = args.moderation_config; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [{ + type: 'text', + text: `Config update queued for ${args.jid}.` + }] + }; + } + ), ] }); } diff --git a/container/build.sh b/container/build.sh index 6fb235f..c5dc83f 100755 --- a/container/build.sh +++ b/container/build.sh @@ -1,5 +1,6 @@ #!/bin/bash -# Build the NanoClaw agent container image +# Build the GroupGuard agent container image +# Supports both Docker and Apple Containers (macOS 26+) set -e @@ -9,15 +10,40 @@ cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" -echo "Building NanoClaw agent container image..." +# Detect container runtime +detect_runtime() { + local env_runtime="${CONTAINER_RUNTIME:-auto}" + + case "$env_runtime" in + apple) + echo "container" + return + ;; + docker) + echo "docker" + return + ;; + esac + + # Auto-detect + if [[ "$(uname -s)" == "Darwin" ]] && command -v container &>/dev/null; then + echo "container" + else + echo "docker" + fi +} + +RUNTIME=$(detect_runtime) + +echo "Building GroupGuard agent container image..." +echo "Runtime: ${RUNTIME}" echo "Image: ${IMAGE_NAME}:${TAG}" -# Build with Docker -docker build -t "${IMAGE_NAME}:${TAG}" . +$RUNTIME 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}' | docker run -i ${IMAGE_NAME}:${TAG}" +echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${RUNTIME} run -i ${IMAGE_NAME}:${TAG}" diff --git a/scripts/health-check.sh b/scripts/health-check.sh new file mode 100755 index 0000000..1b7832d --- /dev/null +++ b/scripts/health-check.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# GroupGuard Health Check +# +# Checks process status, WhatsApp connection, and recent activity. +# Designed to run from cron or a monitoring system. +# +# Exit codes: +# 0 = healthy +# 1 = unhealthy +# +# Usage: +# ./scripts/health-check.sh # Print status +# ./scripts/health-check.sh --webhook URL # POST to webhook on failure +# +set -euo pipefail + +WEBHOOK_URL="${2:-}" +SERVICE_NAME="groupguard" +LOG_FILE="/tmp/groupguard-health.log" +MAX_IDLE_SECONDS="${MAX_IDLE_SECONDS:-3600}" # Alert if no activity for 1 hour + +healthy=true +issues=() + +check_process() { + if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + echo "[OK] Service is running" + else + echo "[FAIL] Service is not running" + issues+=("Service not running") + healthy=false + fi +} + +check_docker() { + if docker info &>/dev/null; then + echo "[OK] Docker is running" + else + echo "[FAIL] Docker is not running" + issues+=("Docker not running") + healthy=false + fi +} + +check_disk() { + local usage + usage=$(df / --output=pcent | tail -1 | tr -d ' %') + if [ "$usage" -lt 90 ]; then + echo "[OK] Disk usage: ${usage}%" + else + echo "[WARN] Disk usage high: ${usage}%" + issues+=("Disk usage ${usage}%") + if [ "$usage" -ge 95 ]; then + healthy=false + fi + fi +} + +check_memory() { + local mem_pct + mem_pct=$(free | awk '/Mem:/ {printf "%.0f", $3/$2 * 100}') + if [ "$mem_pct" -lt 90 ]; then + echo "[OK] Memory usage: ${mem_pct}%" + else + echo "[WARN] Memory usage high: ${mem_pct}%" + issues+=("Memory usage ${mem_pct}%") + fi +} + +check_recent_logs() { + # Check if there are recent logs (service is actually doing something) + if journalctl -u "$SERVICE_NAME" --since "5 min ago" --no-pager -q 2>/dev/null | grep -q .; then + echo "[OK] Recent log activity found" + else + echo "[WARN] No log activity in last 5 minutes" + issues+=("No recent log activity") + fi +} + +check_whatsapp_connection() { + # Look for WhatsApp connection status in recent logs + local last_connected + last_connected=$(journalctl -u "$SERVICE_NAME" --no-pager -q 2>/dev/null | \ + grep -o "Connected to WhatsApp" | tail -1 || true) + + if [ -n "$last_connected" ]; then + echo "[OK] WhatsApp connection established" + else + echo "[INFO] No WhatsApp connection log found (may be normal on first run)" + fi + + # Check for disconnect errors + local recent_errors + recent_errors=$(journalctl -u "$SERVICE_NAME" --since "10 min ago" --no-pager -q 2>/dev/null | \ + grep -c "Connection closed\|loggedOut\|authentication required" || true) + + if [ "$recent_errors" -gt 0 ]; then + echo "[WARN] Found $recent_errors connection issues in last 10 minutes" + issues+=("$recent_errors connection issues in last 10 min") + fi +} + +check_containers() { + # Check for stuck/orphaned containers + local running + running=$(docker ps --filter "name=nanoclaw-" --format "{{.Names}} ({{.Status}})" 2>/dev/null || true) + + if [ -n "$running" ]; then + echo "[INFO] Running containers:" + echo "$running" | while read -r line; do echo " $line"; done + else + echo "[OK] No running agent containers" + fi +} + +send_webhook() { + if [ -z "$WEBHOOK_URL" ]; then return; fi + + local status="unhealthy" + local message + message=$(printf '%s\n' "${issues[@]}" | head -5) + + curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "{ + \"status\": \"$status\", + \"service\": \"$SERVICE_NAME\", + \"issues\": \"$message\", + \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", + \"hostname\": \"$(hostname)\" + }" > /dev/null 2>&1 || echo "[WARN] Failed to send webhook" +} + +# Run all checks +echo "=== GroupGuard Health Check ===" +echo "Time: $(date)" +echo "" + +check_process +check_docker +check_disk +check_memory +check_recent_logs +check_whatsapp_connection +check_containers + +echo "" +if $healthy; then + echo "Status: HEALTHY" + exit 0 +else + echo "Status: UNHEALTHY" + echo "Issues: ${issues[*]}" + send_webhook + exit 1 +fi diff --git a/scripts/macos-networking.sh b/scripts/macos-networking.sh index fa3e77f..4377b2f 100755 --- a/scripts/macos-networking.sh +++ b/scripts/macos-networking.sh @@ -1,12 +1,23 @@ #!/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. +# macOS networking setup for Apple Containers and non-Desktop Docker +# +# Apple Containers use vmnet bridge (192.168.64.0/24) which needs: +# - IP forwarding enabled +# - NAT rules for outbound traffic +# +# Docker Desktop handles this automatically — this script is only needed for +# Apple Containers or alternative Docker installations (colima, lima, etc.). # # Usage: sudo ./scripts/macos-networking.sh +# sudo ./scripts/macos-networking.sh --non-interactive set -e +NON_INTERACTIVE=false +if [[ "$1" == "--non-interactive" ]]; then + NON_INTERACTIVE=true +fi + # Check if running as root if [[ $EUID -ne 0 ]]; then echo "This script must be run with sudo:" @@ -15,7 +26,7 @@ if [[ $EUID -ne 0 ]]; then fi # Check if Docker Desktop is handling networking -if docker info 2>/dev/null | grep -q "Desktop"; then +if command -v docker &>/dev/null && 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 @@ -30,25 +41,34 @@ fi echo "Active interface: $INTERFACE" -# Step 1: Enable IP forwarding -echo "Enabling IP forwarding..." -sysctl -w net.inet.ip.forwarding=1 +# Step 1: Enable IP forwarding (idempotent) +CURRENT_FWD=$(sysctl -n net.inet.ip.forwarding 2>/dev/null || echo "0") +if [[ "$CURRENT_FWD" != "1" ]]; then + echo "Enabling IP forwarding..." + sysctl -w net.inet.ip.forwarding=1 +else + echo "IP forwarding already enabled." +fi -# 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 +# Step 2: Add NAT rule (idempotent — check if rule already active) +if pfctl -s nat 2>/dev/null | grep -q "192.168.64.0/24"; then + echo "NAT rule already active." +else + echo "Adding NAT rule for container bridge network..." + echo "nat on $INTERFACE from 192.168.64.0/24 to any -> ($INTERFACE)" | pfctl -ef - 2>/dev/null +fi 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" +PLIST="/Library/LaunchDaemons/com.groupguard.networking.plist" +if [[ -f "$PLIST" ]]; then + echo "Persistence already configured ($PLIST exists)." +elif [[ "$NON_INTERACTIVE" == true ]]; then + # Non-interactive mode: always install persistence cat > "$PLIST" << EOF @@ -70,6 +90,33 @@ EOF launchctl load "$PLIST" 2>/dev/null || true echo "Persistence configured via $PLIST" +else + read -p "Make persistent across reboots? (y/N) " -n 1 -r + echo "" + + if [[ $REPLY =~ ^[Yy]$ ]]; then + 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 fi echo "Done." diff --git a/setup.sh b/setup.sh index 864247d..607f77e 100755 --- a/setup.sh +++ b/setup.sh @@ -1,9 +1,9 @@ #!/bin/bash -# GroupGuard / NanoClaw One-Command Setup +# GroupGuard One-Command Setup # # Usage: ./setup.sh # -# Prerequisites: Node.js 20+, Docker +# Prerequisites: Node.js 20+, Docker or Apple Containers (macOS 26+) set -e @@ -35,16 +35,52 @@ if [[ "$NODE_VERSION" -lt 20 ]]; then 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 +# --- Step 2: Check container runtime --- +echo "Step 2: Checking container runtime..." + +OS="$(uname -s)" +RUNTIME="" + +if [[ "$OS" == "Darwin" ]]; then + # macOS: prefer Apple Containers, fall back to Docker + if command -v container &>/dev/null; then + RUNTIME="container" + ok "Apple Containers (macOS)" + + # Set up networking for Apple Containers + if [[ -f "$PROJECT_ROOT/scripts/macos-networking.sh" ]]; then + echo " Setting up Apple Container networking..." + if sudo "$PROJECT_ROOT/scripts/macos-networking.sh" --non-interactive 2>/dev/null; then + ok "Networking configured" + else + warn "Networking setup may need manual steps. Run: sudo ./scripts/macos-networking.sh" + fi + fi + elif command -v docker &>/dev/null; then + RUNTIME="docker" + if ! docker info &>/dev/null; then + fail "Docker is not running. Start Docker Desktop or run: sudo systemctl start docker" + fi + ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')" -if ! docker info &>/dev/null; then - fail "Docker is not running. Start Docker Desktop (macOS) or run 'sudo systemctl start docker' (Linux)" + # Check if non-Desktop Docker needs 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 + else + fail "No container runtime found. Install Docker Desktop from https://docker.com or use Apple Containers on macOS 26+" + fi +else + # Linux/other: Docker only + if ! command -v docker &>/dev/null; then + fail "Docker not found. Install from https://docker.com" + fi + if ! docker info &>/dev/null; then + fail "Docker is not running. Run: sudo systemctl start docker" + fi + RUNTIME="docker" + ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')" fi -ok "Docker $(docker --version | awk '{print $3}' | tr -d ',')" # --- Step 3: Install dependencies --- echo "Step 3: Installing dependencies..." @@ -53,8 +89,8 @@ 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 +CONTAINER_RUNTIME="$( [[ "$RUNTIME" == "container" ]] && echo "apple" || echo "docker" )" ./container/build.sh +if $RUNTIME run --rm --entrypoint echo nanoclaw-agent:latest "OK" &>/dev/null; then ok "Container image built and verified" else fail "Container image build failed" @@ -82,7 +118,6 @@ ok "TypeScript compiled" # --- Step 7: Install service --- echo "Step 7: Installing service..." -OS="$(uname -s)" case "$OS" in Darwin) echo " Detected: macOS" @@ -91,6 +126,13 @@ case "$OS" in NODE_PATH=$(which node) HOME_PATH="$HOME" + # Set CONTAINER_RUNTIME env var so the service uses the right runtime + RUNTIME_ENV="" + if [[ "$RUNTIME" == "container" ]]; then + RUNTIME_ENV=" CONTAINER_RUNTIME + apple" + fi + cat > ~/Library/LaunchAgents/com.nanoclaw.plist << EOF @@ -115,6 +157,7 @@ case "$OS" in /usr/local/bin:/usr/bin:/bin:${HOME_PATH}/.local/bin HOME ${HOME_PATH} +${RUNTIME_ENV} StandardOutPath ${PROJECT_ROOT}/logs/nanoclaw.log @@ -127,11 +170,6 @@ 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) @@ -162,6 +200,7 @@ echo "" echo "=== Setup Complete ===" echo "" echo "Your assistant is running! Send a message in WhatsApp to test." +echo "Runtime: ${RUNTIME}" echo "" echo "Useful commands:" echo " npm run dev - Run in development mode (with hot reload)" diff --git a/src/container-runner.ts b/src/container-runner.ts index a32a3e8..47f0e9d 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,9 +1,9 @@ /** - * Container Runner for NanoClaw - * Spawns agent execution in Docker and handles IPC + * Container Runner for GroupGuard + * Spawns agent execution in Docker or Apple Containers and handles IPC */ -import { spawn } from 'child_process'; +import { spawn, execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -13,7 +13,8 @@ import { CONTAINER_TIMEOUT, CONTAINER_MAX_OUTPUT_SIZE, GROUPS_DIR, - DATA_DIR + DATA_DIR, + MAIN_GROUP_FOLDER } from './config.js'; import { RegisteredGroup } from './types.js'; import { validateAdditionalMounts } from './mount-security.js'; @@ -27,6 +28,84 @@ const logger = pino({ const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; +// Secret env vars that should never appear in logs or container filesystems +const SECRET_VAR_NAMES = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +// --- Runtime Detection --- + +type ContainerRuntime = 'docker' | 'apple'; + +let cachedRuntime: ContainerRuntime | null = null; + +/** + * Detect which container runtime to use. + * Checks CONTAINER_RUNTIME env var first, then auto-detects. + * On macOS: prefers Apple Containers (`container` CLI) if available, falls back to Docker. + * On Linux/Windows: always Docker. + */ +export function detectRuntime(): string { + if (cachedRuntime) { + return cachedRuntime === 'apple' ? 'container' : 'docker'; + } + + const envRuntime = process.env.CONTAINER_RUNTIME?.toLowerCase(); + + if (envRuntime === 'apple') { + cachedRuntime = 'apple'; + return 'container'; + } + + if (envRuntime === 'docker') { + cachedRuntime = 'docker'; + return 'docker'; + } + + // Auto-detect + if (os.platform() === 'darwin') { + try { + execSync('which container', { stdio: 'pipe' }); + cachedRuntime = 'apple'; + logger.info('Detected Apple Containers runtime'); + return 'container'; + } catch { + // Apple Containers not available, fall through to Docker + } + } + + cachedRuntime = 'docker'; + logger.info('Using Docker runtime'); + return 'docker'; +} + +function getRuntimeType(): ContainerRuntime { + if (!cachedRuntime) detectRuntime(); + return cachedRuntime!; +} + +/** + * Read secrets from .env file. Returns only allowed auth variables. + * These are passed via stdin JSON to the container, never written to disk. + */ +function readSecrets(): Record { + const envFile = path.join(process.cwd(), '.env'); + const secrets: Record = {}; + + if (!fs.existsSync(envFile)) return secrets; + + const envContent = fs.readFileSync(envFile, 'utf-8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + for (const varName of SECRET_VAR_NAMES) { + if (trimmed.startsWith(`${varName}=`)) { + secrets[varName] = trimmed.slice(varName.length + 1); + } + } + } + + return secrets; +} + function getHomeDir(): string { const home = process.env.HOME || os.homedir(); if (!home) { @@ -42,6 +121,7 @@ export interface ContainerInput { chatJid: string; isMain: boolean; isScheduledTask?: boolean; + secrets?: Record; } export interface ContainerOutput { @@ -59,31 +139,24 @@ interface VolumeMount { function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] { const mounts: VolumeMount[] = []; - const homeDir = getHomeDir(); - const projectRoot = process.cwd(); if (isMain) { // Main gets the entire project root mounted mounts.push({ - hostPath: projectRoot, + hostPath: process.cwd(), containerPath: '/workspace/project', readonly: false }); + } - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: path.join(GROUPS_DIR, group.folder), - containerPath: '/workspace/group', - readonly: false - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: path.join(GROUPS_DIR, group.folder), - containerPath: '/workspace/group', - readonly: false - }); + // Group's own folder + mounts.push({ + hostPath: path.join(GROUPS_DIR, group.folder), + containerPath: '/workspace/group', + readonly: false + }); + if (!isMain) { // Global memory directory (read-only for non-main) const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { @@ -96,51 +169,26 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount } // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); - fs.mkdirSync(groupSessionsDir, { recursive: true }); + const sessionsPath = path.join(DATA_DIR, 'sessions', group.folder, '.claude'); + fs.mkdirSync(sessionsPath, { recursive: true }); mounts.push({ - hostPath: groupSessionsDir, + hostPath: sessionsPath, containerPath: '/home/node/.claude', readonly: false }); - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + // Per-group IPC namespace + const ipcPath = path.join(DATA_DIR, 'ipc', group.folder); + fs.mkdirSync(path.join(ipcPath, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(ipcPath, 'tasks'), { recursive: true }); mounts.push({ - hostPath: groupIpcDir, + hostPath: ipcPath, containerPath: '/workspace/ipc', readonly: false }); - // 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 }); - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - const envContent = fs.readFileSync(envFile, 'utf-8'); - const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; - const filteredLines = envContent - .split('\n') - .filter(line => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return false; - return allowedVars.some(v => trimmed.startsWith(`${v}=`)); - }); - - if (filteredLines.length > 0) { - fs.writeFileSync(path.join(envDir, 'env'), filteredLines.join('\n') + '\n'); - mounts.push({ - hostPath: envDir, - containerPath: '/workspace/env-dir', - readonly: true - }); - } - } + // NOTE: Secrets (API keys) are passed via stdin JSON, not mounted as files. + // This prevents agents from reading them via `cat`, `env`, or `/proc/self/environ`. // Additional mounts validated against external allowlist (tamper-proof from containers) if (group.containerConfig?.additionalMounts) { @@ -156,7 +204,28 @@ function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount } function buildContainerArgs(mounts: VolumeMount[]): string[] { - const args: string[] = ['run', '-i', '--rm', '-e', 'NODE_OPTIONS=--dns-result-order=ipv4first']; + const runtime = getRuntimeType(); + const args: string[] = [ + 'run', '-i', '--rm', + '-e', 'NODE_OPTIONS=--dns-result-order=ipv4first', + ]; + + if (runtime === 'docker') { + // Docker-specific security hardening flags + args.push( + '--security-opt', 'no-new-privileges', + '--memory=512m', + '--cpus=1', + '--read-only', + '--tmpfs', '/tmp:rw,noexec,nosuid,size=256m', + ); + } else { + // Apple Containers — different flag syntax + args.push( + '--memory=512m', + '--cpus=1', + ); + } for (const mount of mounts) { if (mount.readonly) { @@ -176,6 +245,7 @@ export async function runContainerAgent( input: ContainerInput ): Promise { const startTime = Date.now(); + const runtime = detectRuntime(); const groupDir = path.join(GROUPS_DIR, group.folder); fs.mkdirSync(groupDir, { recursive: true }); @@ -185,6 +255,7 @@ export async function runContainerAgent( logger.debug({ group: group.name, + runtime, mounts: mounts.map(m => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`), containerArgs: containerArgs.join(' ') }, 'Container mount configuration'); @@ -192,14 +263,19 @@ export async function runContainerAgent( logger.info({ group: group.name, mountCount: mounts.length, - isMain: input.isMain + isMain: input.isMain, + runtime }, 'Spawning container agent'); + // Inject secrets into stdin payload (never written to disk or passed as env vars) + const secrets = readSecrets(); + const stdinPayload = { ...input, secrets }; + const logsDir = path.join(GROUPS_DIR, group.folder, 'logs'); fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { - const container = spawn('docker', containerArgs, { + const container = spawn(runtime, containerArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -208,7 +284,7 @@ export async function runContainerAgent( let stdoutTruncated = false; let stderrTruncated = false; - container.stdin.write(JSON.stringify(input)); + container.stdin.write(JSON.stringify(stdinPayload)); container.stdin.end(); container.stdout.on('data', (data) => { @@ -263,6 +339,7 @@ export async function runContainerAgent( `=== Container Run Log ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, + `Runtime: ${runtime}`, `IsMain: ${input.isMain}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, @@ -272,9 +349,11 @@ export async function runContainerAgent( ]; if (isVerbose) { + // Strip secrets from logged input + const { secrets: _secrets, ...safeInput } = stdinPayload; logLines.push( `=== Input ===`, - JSON.stringify(input, null, 2), + JSON.stringify(safeInput, null, 2), ``, `=== Container Args ===`, containerArgs.join(' '), @@ -390,7 +469,7 @@ export function writeTasksSnapshot( schedule_value: string; status: string; next_run: string | null; - }> + }>, ): void { // Write filtered tasks to the group's IPC directory const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); @@ -414,19 +493,19 @@ export interface AvailableGroup { /** * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. + * Only main group can see all available groups (for registration). + * Non-main groups see nothing (they can't register groups). */ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, groups: AvailableGroup[], - registeredJids: Set + registeredJids: Set, ): void { const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); - // Main sees all groups; others see nothing (they can't activate groups) + // Main sees all groups; others see nothing const visibleGroups = isMain ? groups : []; const groupsFile = path.join(groupIpcDir, 'available_groups.json'); diff --git a/src/index.ts b/src/index.ts index 96a9567..10c33dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,12 +15,12 @@ import { STORE_DIR, DATA_DIR, TRIGGER_PATTERN, - MAIN_GROUP_FOLDER, IPC_POLL_INTERVAL, - TIMEZONE + TIMEZONE, + MAIN_GROUP_FOLDER } from './config.js'; import { RegisteredGroup, Session, NewMessage } from './types.js'; -import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync } from './db.js'; +import { initDatabase, storeMessage, storeChatMetadata, getNewMessages, getMessagesSince, getAllTasks, getTaskById, updateChatName, getAllChats, getLastGroupSync, setLastGroupSync, getModerationStats, getModerationLogs } from './db.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { runContainerAgent, writeTasksSnapshot, writeGroupsSnapshot, AvailableGroup } from './container-runner.js'; import { loadJson, saveJson } from './utils.js'; @@ -134,10 +134,10 @@ async function processMessage(msg: NewMessage): Promise { if (!group) return; const content = msg.content.trim(); - const isMainGroup = group.folder === MAIN_GROUP_FOLDER; + const isMain = group.folder === MAIN_GROUP_FOLDER; // Main group responds to all messages; other groups require trigger prefix - if (!isMainGroup && !TRIGGER_PATTERN.test(content)) return; + if (!isMain && !TRIGGER_PATTERN.test(content)) return; // Get all messages since last agent interaction so the session has full context const sinceTimestamp = lastAgentTimestamp[msg.chat_jid] || ''; @@ -176,21 +176,26 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) const isMain = group.folder === MAIN_GROUP_FOLDER; const sessionId = sessions[group.folder]; - // Update tasks snapshot for container to read (filtered by group) + // Update tasks snapshot for container to read const tasks = getAllTasks(); - writeTasksSnapshot(group.folder, isMain, tasks.map(t => ({ - id: t.id, - groupFolder: t.group_folder, - prompt: t.prompt, - schedule_type: t.schedule_type, - schedule_value: t.schedule_value, - status: t.status, - next_run: t.next_run - }))); - - // Update available groups snapshot (main group only can see all groups) + writeTasksSnapshot( + group.folder, + isMain, + tasks.map(t => ({ + id: t.id, + groupFolder: t.group_folder, + prompt: t.prompt, + schedule_type: t.schedule_type, + schedule_value: t.schedule_value, + status: t.status, + next_run: t.next_run + })), + ); + + // Update available groups snapshot (main group can see all WhatsApp groups) const availableGroups = getAvailableGroups(); - writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups))); + const registeredJids = new Set(Object.keys(registeredGroups)); + writeGroupsSnapshot(group.folder, isMain, availableGroups, registeredJids); try { const output = await runContainerAgent(group, { @@ -198,7 +203,7 @@ async function runAgent(group: RegisteredGroup, prompt: string, chatJid: string) sessionId, groupFolder: group.folder, chatJid, - isMain + isMain, }); if (output.newSessionId) { @@ -259,7 +264,7 @@ function startIpcWatcher(): void { try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (data.type === 'message' && data.chatJid && data.text) { - // Authorization: verify this group can send to this chatJid + // Authorization: main group can send anywhere, others only to their own chat const targetGroup = registeredGroups[data.chatJid]; if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) { await sendMessage(data.chatJid, `${ASSISTANT_NAME}: ${data.text}`); @@ -289,7 +294,7 @@ function startIpcWatcher(): void { const filePath = path.join(tasksDir, file); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - // Pass source group identity to processTaskIpc for authorization + // Pass source group identity for authorization await processTaskIpc(data, sourceGroup, isMain); fs.unlinkSync(filePath); } catch (err) { @@ -331,24 +336,24 @@ async function processTaskIpc( guards?: RegisteredGroup['guards']; moderationConfig?: RegisteredGroup['moderationConfig']; }, - sourceGroup: string, // Verified identity from IPC directory - isMain: boolean // Verified from directory path + sourceGroup: string, + isMain: boolean, ): Promise { - // Import db functions dynamically to avoid circular deps const { createTask, updateTask, deleteTask, getTaskById: getTask } = await import('./db.js'); const { CronExpressionParser } = await import('cron-parser'); switch (data.type) { case 'schedule_task': if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) { - // Authorization: non-main groups can only schedule for themselves const targetGroup = data.groupFolder; + + // Authorization: non-main groups can only schedule for themselves if (!isMain && targetGroup !== sourceGroup) { logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked'); break; } - // Resolve the correct JID for the target group (don't trust IPC payload) + // Resolve the correct JID for the target group const targetJid = Object.entries(registeredGroups).find( ([, group]) => group.folder === targetGroup )?.[0]; @@ -448,8 +453,9 @@ async function processTaskIpc( await syncGroupMetadata(true); // Write updated snapshot immediately const availableGroups = getAvailableGroups(); + const registeredJids = new Set(Object.keys(registeredGroups)); const { writeGroupsSnapshot: writeGroups } = await import('./container-runner.js'); - writeGroups(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups))); + writeGroups(sourceGroup, true, availableGroups, registeredJids); } else { logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked'); } @@ -467,7 +473,7 @@ async function processTaskIpc( folder: data.folder, trigger: data.trigger, added_at: new Date().toISOString(), - containerConfig: data.containerConfig + containerConfig: data.containerConfig, }); } else { logger.warn({ data }, 'Invalid register_group request - missing required fields'); @@ -519,7 +525,7 @@ async function connectWhatsApp(): Promise { if (qr) { const msg = 'WhatsApp authentication required. Run /setup in Claude Code.'; logger.error(msg); - exec(`osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`); + exec(`osascript -e 'display notification "${msg}" with title "GroupGuard" sound name "Basso"'`); setTimeout(() => process.exit(1), 1000); } @@ -582,12 +588,12 @@ async function connectWhatsApp(): Promise { // Always store chat metadata for group discovery storeChatMetadata(chatJid, timestamp); - // Only process full message content for registered groups + // Process messages for registered groups if (registeredGroups[chatJid]) { const group = registeredGroups[chatJid]; // Run moderation guards BEFORE storing the message - if (group.guards && group.guards.length > 0) { + if (group?.guards && group.guards.length > 0 && chatJid.endsWith('@g.us')) { const blocked = await moderateMessage( msg, chatJid, @@ -618,8 +624,8 @@ async function startMessageLoop(): Promise { while (true) { try { - const jids = Object.keys(registeredGroups); - const { messages } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME); + const registeredJids = Object.keys(registeredGroups); + const { messages } = getNewMessages(registeredJids, lastTimestamp, ASSISTANT_NAME); if (messages.length > 0) logger.info({ count: messages.length }, 'New messages'); for (const msg of messages) { @@ -641,30 +647,31 @@ async function startMessageLoop(): Promise { } } -function ensureDockerRunning(): void { +function ensureRuntimeRunning(): void { + const runtime = detectRuntime(); try { - execSync('docker info', { stdio: 'pipe', timeout: 10000 }); - logger.debug('Docker is running'); + execSync(`${runtime} info`, { stdio: 'pipe', timeout: 10000 }); + logger.debug({ runtime }, 'Container runtime is running'); } catch { console.error('\n╔════════════════════════════════════════════════════════╗'); - console.error('║ FATAL: Docker is not running ║'); + console.error('║ FATAL: Container runtime is not running ║'); console.error('║ ║'); - console.error('║ Agents cannot run without Docker. To fix: ║'); - console.error('║ • macOS: Start Docker Desktop ║'); + console.error('║ Agents cannot run without a container runtime. ║'); + console.error('║ • macOS: Start Docker Desktop or use Apple Containers ║'); console.error('║ • Linux: sudo systemctl start docker ║'); console.error('╚════════════════════════════════════════════════════════╝\n'); - throw new Error('Docker is required but not running'); + throw new Error('Container runtime 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}}"', + `${runtime} 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 */ } + try { execSync(`${runtime} stop ${name}`, { stdio: 'pipe' }); } catch { /* already stopped */ } } if (orphans.length > 0) { logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers'); @@ -674,8 +681,11 @@ function ensureDockerRunning(): void { } } +// Re-export detectRuntime from container-runner for use in startup +import { detectRuntime } from './container-runner.js'; + async function main(): Promise { - ensureDockerRunning(); + ensureRuntimeRunning(); initDatabase(); logger.info('Database initialized'); loadState(); @@ -683,6 +693,6 @@ async function main(): Promise { } main().catch(err => { - logger.error({ err }, 'Failed to start NanoClaw'); + logger.error({ err }, 'Failed to start GroupGuard'); process.exit(1); }); diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 979ed66..496e19d 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -4,7 +4,7 @@ import pino from 'pino'; import { CronExpressionParser } from 'cron-parser'; import { getDueTasks, updateTaskAfterRun, logTaskRun, getTaskById, getAllTasks } from './db.js'; import { ScheduledTask, RegisteredGroup } from './types.js'; -import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, MAIN_GROUP_FOLDER, TIMEZONE } from './config.js'; +import { GROUPS_DIR, SCHEDULER_POLL_INTERVAL, DATA_DIR, TIMEZONE, MAIN_GROUP_FOLDER } from './config.js'; import { runContainerAgent, writeTasksSnapshot } from './container-runner.js'; const logger = pino({ @@ -41,8 +41,9 @@ async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promis return; } - // Update tasks snapshot for container to read (filtered by group) const isMain = task.group_folder === MAIN_GROUP_FOLDER; + + // Update tasks snapshot for container to read const tasks = getAllTasks(); writeTasksSnapshot(task.group_folder, isMain, tasks.map(t => ({ id: t.id,