Fix trigger matching and add network policies

- Trigger only matches when nick is at start of message, not mid-text
  Fixes: "coder: say hi to worker" no longer triggers worker
- Network policies per agent: "full" (default), "local" (LAN only), "none" (IRC+Ollama only)
  Configured via template "network" field, applied as iptables rules per agent IP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 13:45:05 +00:00
parent 6fc6e89917
commit 36af68da90
3 changed files with 91 additions and 3 deletions

View File

@@ -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):

View File

@@ -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);

View File

@@ -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()}`;
}