From 35f59f30f6a36676627ee44d7c23487e6d570b1b Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Thu, 28 May 2026 23:12:11 +0530 Subject: [PATCH] feat(docker): forward /etc/hosts entries to worker containers (#346) --- .env.example | 3 ++ CLAUDE.md | 1 + README.md | 6 +++ apps/cli/src/docker.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/.env.example b/.env.example index 7ef48c1..82e80fa 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,9 @@ CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 # Adaptive thinking is enabled automatically on Opus 4.6/4.7. Set to false to disable. # CLAUDE_ADAPTIVE_THINKING=false +# Shannon forwards your machine's /etc/hosts entries into the worker container. Set to false to disable. +# SHANNON_FORWARD_HOSTS=false + # ============================================================================= # OPTION 1: Direct Anthropic # ============================================================================= diff --git a/CLAUDE.md b/CLAUDE.md index 2fbf65d..e10dafd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ Infra (Temporal) runs via `docker-compose.yml`. Workers are ephemeral `docker ru - `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233). Network: `shannon-net` - `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Uses pnpm. Entrypoint: `CMD ["node", "apps/worker/dist/temporal/worker.js"]` - No `docker-compose.docker.yml` — host gateway handled via `--add-host` flag in CLI +- `/etc/hosts` forwarding — at worker spawn, `forwardEtcHostsFlags` in `apps/cli/src/docker.ts` reads the host's `/etc/hosts` and emits one `--add-host` flag per valid user-added entry. Loopback IPs (`127.x`, `::1`) are rewritten to `host-gateway`; IPv6 addresses are bracketed. Disable per-scan via `SHANNON_FORWARD_HOSTS=false`. No-op on Windows native (WSL2 reads its own `/etc/hosts` via the Linux path). ### Worker Package (`apps/worker/`) - `apps/worker/src/paths.ts` — Centralized path constants (`PROMPTS_DIR`, `CONFIGS_DIR`, `WORKSPACES_DIR`) diff --git a/README.md b/README.md index 6d75757..b6dca03 100644 --- a/README.md +++ b/README.md @@ -685,6 +685,12 @@ npx @keygraph/shannon start -u http://host.docker.internal:3000 -r /path/to/repo +**Custom hostnames in `/etc/hosts`:** + +If your local stack uses custom hostnames mapped in `/etc/hosts`, Shannon forwards those entries into the worker container at scan start: + +To disable, add `SHANNON_FORWARD_HOSTS=false` to `.env` (local mode) or export it in your shell: `export SHANNON_FORWARD_HOSTS=false`. In npx mode, the shell export is the only option since there's no `.env`. + ### Output and Results All results are saved to the workspaces directory: `./workspaces/` (local mode) or `~/.shannon/workspaces/` (npx mode). Use `-o ` to copy deliverables to a custom output directory after the run completes. diff --git a/apps/cli/src/docker.ts b/apps/cli/src/docker.ts index dc3d199..caef82c 100644 --- a/apps/cli/src/docker.ts +++ b/apps/cli/src/docker.ts @@ -7,6 +7,7 @@ import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; import crypto from 'node:crypto'; +import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -145,6 +146,87 @@ function addHostFlag(): string[] { return []; } +/** + * Names whose standard IPs aren't covered by `shouldSkipHostsIp`. Loopback names + * stay because their IPs (127.x, ::1) get rewritten — not skipped. Others like + * `broadcasthost` and `ip6-mcastprefix` are intentionally omitted: their IPs + * (255.255.255.255, ff00::/8) are already dropped at the IP filter. + */ +const HOSTS_SKIP_NAMES = new Set([ + 'localhost', + 'ip6-localhost', + 'ip6-loopback', + 'ip6-localnet', + 'host.docker.internal', + 'gateway.docker.internal', + 'kubernetes.docker.internal', +]); + +function isLoopbackIp(ip: string): boolean { + return ip.startsWith('127.') || ip === '::1'; +} + +function shouldSkipHostsIp(ip: string): boolean { + if (ip === '0.0.0.0' || ip === '255.255.255.255') return true; + // Cloud metadata range — consistent with Shannon's SSRF guard + if (ip.startsWith('169.254.')) return true; + const lower = ip.toLowerCase(); + if (lower.startsWith('fe80:') || lower.startsWith('ff')) return true; + return false; +} + +function shouldSkipHostsName(name: string, hostname: string): boolean { + const lower = name.toLowerCase(); + if (HOSTS_SKIP_NAMES.has(lower)) return true; + if (lower === hostname.toLowerCase()) return true; + if (lower.endsWith('.localhost')) return true; + return false; +} + +/** + * Read the host's /etc/hosts and emit --add-host flags so the worker resolves + * user-added entries the same way. Loopback IPs (127.x, ::1) are rewritten to + * `host-gateway` so they target the host's loopback instead of the container's. + */ +function forwardEtcHostsFlags(): string[] { + if (process.env.SHANNON_FORWARD_HOSTS === 'false') return []; + if (os.platform() === 'win32') return []; + + let content: string; + try { + content = fs.readFileSync('/etc/hosts', 'utf-8'); + } catch { + return []; + } + + const hostname = os.hostname(); + const flags: string[] = []; + + for (const rawLine of content.split('\n')) { + const hashIdx = rawLine.indexOf('#'); + const line = (hashIdx >= 0 ? rawLine.slice(0, hashIdx) : rawLine).trim(); + if (!line) continue; + + const tokens = line + .split(' ') + .flatMap((t) => t.split('\t')) + .filter(Boolean); + const ip = tokens[0]; + const names = tokens.slice(1); + if (!ip || names.length === 0) continue; + if (shouldSkipHostsIp(ip)) continue; + + const targetIp = isLoopbackIp(ip) ? 'host-gateway' : ip; + const formattedIp = targetIp.includes(':') ? `[${targetIp}]` : targetIp; + for (const name of names) { + if (shouldSkipHostsName(name, hostname)) continue; + flags.push('--add-host', `${name}:${formattedIp}`); + } + } + + return flags; +} + export interface WorkerOptions { version: string; url: string; @@ -176,6 +258,9 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess { // Add host flag for Linux args.push(...addHostFlag()); + // Forward user-added /etc/hosts entries into the worker + args.push(...forwardEtcHostsFlags()); + // UID remapping for Linux bind mounts if (os.platform() === 'linux' && process.getuid && process.getgid) { args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);