diff --git a/agent/agent.py b/agent/agent.py index 106bbd4..b6f6c54 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -365,11 +365,22 @@ def build_messages(question, channel): def should_trigger(text): - """Check if this message should trigger a response.""" + """Check if this message should trigger a response. + Only triggers when nick is at the start of the message (e.g. 'worker: hello') + not when nick appears elsewhere (e.g. 'coder: say hi to worker').""" if RUNTIME["trigger"] == "all": return True lower = text.lower() - return NICK.lower() in lower or text.startswith("!ask ") + nick = NICK.lower() + # Match: "nick: ...", "nick, ...", "nick ...", "@nick ..." + return ( + lower.startswith(f"{nick}:") or + lower.startswith(f"{nick},") or + lower.startswith(f"{nick} ") or + lower.startswith(f"@{nick}") or + lower == nick or + text.startswith("!ask ") + ) def extract_question(text): diff --git a/src/agent-manager.ts b/src/agent-manager.ts index 65bd5b6..bded8fe 100644 --- a/src/agent-manager.ts +++ b/src/agent-manager.ts @@ -19,6 +19,9 @@ import { createTap, deleteTap, macFromOctet, + applyNetworkPolicy, + removeNetworkPolicy, + type NetworkPolicy, } from "./network.js"; import * as api from "./firecracker-api.js"; @@ -42,6 +45,7 @@ interface AgentTemplate { model: string; trigger: string; persona: string; + network?: NetworkPolicy; } const AGENTS_FILE = join(CONFIG.baseDir, "agents.json"); @@ -299,6 +303,10 @@ export async function startAgent( ); await api.startInstance(socketPath); + // Apply network policy + const networkPolicy: NetworkPolicy = template.network ?? "full"; + applyNetworkPolicy(ip, networkPolicy); + const info: AgentInfo = { name, nick, @@ -366,10 +374,11 @@ export async function stopAgent(name: string) { // Small delay to let kernel release the tap device await new Promise((r) => setTimeout(r, 500)); - // Cleanup with retry for tap + // Cleanup try { unlinkSync(info.socketPath); } catch {} + removeNetworkPolicy(info.ip); for (let attempt = 0; attempt < 3; attempt++) { try { deleteTap(info.tapDevice); diff --git a/src/network.ts b/src/network.ts index b6fd1d7..c3878fb 100644 --- a/src/network.ts +++ b/src/network.ts @@ -111,6 +111,74 @@ export function deleteTap(tapName: string) { } } +export type NetworkPolicy = "full" | "local" | "none"; + +export function applyNetworkPolicy(ip: string, policy: NetworkPolicy) { + if (policy === "full") return; // Default, no restrictions + + // Block outbound internet — only allow LAN, bridge, Ollama, IRC + const allowedDests = [ + CONFIG.bridge.subnet, // bridge network (other VMs, IRC, Ollama) + "192.168.0.0/16", // LAN + ]; + + if (policy === "none") { + // Only allow bridge subnet (IRC + Ollama) + allowedDests.length = 1; // keep only bridge subnet + } + + // Allow established connections back + sudo([ + "iptables", "-I", "FORWARD", + "-s", ip, + "-m", "state", "--state", "ESTABLISHED,RELATED", + "-j", "ACCEPT", + ]); + + // Allow specific destinations + for (const dest of allowedDests) { + sudo([ + "iptables", "-I", "FORWARD", + "-s", ip, "-d", dest, + "-j", "ACCEPT", + ]); + } + + // Drop everything else from this IP + sudo([ + "iptables", "-A", "FORWARD", + "-s", ip, + "-j", "DROP", + ]); +} + +export function removeNetworkPolicy(ip: string) { + // Remove all FORWARD rules mentioning this IP + // Run in a loop since there may be multiple rules + for (let i = 0; i < 10; i++) { + try { + sudo([ + "iptables", "-D", "FORWARD", + "-s", ip, + "-j", "DROP", + ]); + } catch { + break; + } + } + for (let i = 0; i < 10; i++) { + try { + sudo([ + "iptables", "-D", "FORWARD", + "-s", ip, + "-j", "ACCEPT", + ]); + } catch { + break; + } + } +} + export function macFromOctet(octet: number): string { return `AA:FC:00:00:00:${octet.toString(16).padStart(2, "0").toUpperCase()}`; }