/** * Command-Intent Guard Extension (Phase 4 — north-star §5.1, defense layer 3) * * Blocks bash commands that could touch the agent's own container or bypass * the socket proxy: raw-socket access, DOCKER_HOST overrides, framework-layer * compose invocations, --force-recreate, --remove-orphans, docker/compose * lifecycle verbs aimed at the pi service, and bare compose lifecycle * invocations that name no service (which act on the whole file set — the * pi service included). Layers 1 (whitelisting socket proxy) and 2 (compose * split + protected paths) are the structural defenses; this one catches * intent early, with an explanation instead of a downstream failure. * * Bay lifecycle is untouched: `garaje bay ` (and even raw * docker/compose against bay services) passes through. * * Accepted limitations (layer 3 of 3; backstopped by the socket proxy and * the bays-only compose scope): absolute-path invocations (/usr/bin/docker), * --file long-flag and nested -f paths, quoted DOCKER_HOST pointing at a * non-standard socket, raw HTTP to docker-proxy:2375, and container names * using "pi" without a separator (-pi2). */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; /** A token that means "the docker CLI is being invoked" (start of command or * after a shell separator). Note: \b also matches inside hyphenated * filenames (docker-compose.yml), so downstream checks must require * lifecycle context before blocking. */ const DOCKER_INVOCATION = /(^|[\s;&|(])docker\b/; /** Lifecycle verbs that (re)create, stop, or remove containers. */ const LIFECYCLE = "(up|down|stop|start|restart|rm|kill|pause|unpause|update)"; /** Check for compose lifecycle verbs aimed at the pi service. */ const COMPOSE_LIFECYCLE_PI = new RegExp(`\\bcompose\\b.*\\b${LIFECYCLE}\\b.*\\spi(\\s|$)`); /** Check for docker lifecycle verbs (matches the invocation, not the target yet). */ const DOCKER_LIFECYCLE = new RegExp(`\\bdocker\\s+${LIFECYCLE}\\b`); export function blockedCommandReason(command: string): string | undefined { if (command.includes("/var/run/docker.sock")) { return "raw docker socket access — the agent talks to docker only via the proxy (DOCKER_HOST)"; } if (/DOCKER_HOST=unix:/.test(command)) { return "overriding DOCKER_HOST to a unix socket would bypass the socket proxy"; } if (!DOCKER_INVOCATION.test(command)) return undefined; if (command.includes("--force-recreate")) { return "--force-recreate can recreate the pi service — the container this agent lives in"; } if (command.includes("--remove-orphans")) { return "--remove-orphans would remove the framework services (pi, docker-proxy) that sit outside the bays scope"; } if (/-f\s+(\.\/)?(docker-compose\.ya?ml|compose\.framework\.ya?ml)/.test(command)) { return "compose against the framework layer is host-only; use `garaje bay ` for parked services"; } if (COMPOSE_LIFECYCLE_PI.test(command)) { return "that would touch the pi service — the agent cannot manage its own container (cold changes hand off to the host)"; } if (DOCKER_LIFECYCLE.test(command)) { // The pi container's name puts "pi" as the terminal service component: // -pi or -pi- (also _ separators). A bay name // that merely CONTAINS "pi" (raspberry-pi-web-1) must not match. const targetsPi = command.split(/\s+/).some((tok) => /[-_]pi([-_]\d+)?$/.test(tok)); if (targetsPi) { return "that targets the pi container by name — the agent cannot manage its own container"; } } // A bare compose lifecycle invocation (no service token after the verb, up // to the next shell separator) acts on the WHOLE file set — which includes // the pi service — so it must be blocked even though it doesn't name "pi". const composeVerb = new RegExp(`\\bcompose\\b[^;|&]*?\\b${LIFECYCLE}\\b([^;|&]*)`).exec(command); if (composeVerb) { const tail = composeVerb[2].trim().split(/\s+/).filter((t) => t && !t.startsWith("-")); if (tail.length === 0) { return "a compose lifecycle command that names no service acts on the whole file set — which includes the pi service; name bay services explicitly or use `garaje bay `"; } } return undefined; } export default function commandGuardExtension(pi: ExtensionAPI) { pi.on("tool_call", async (event, ctx) => { if (event.toolName !== "bash") return undefined; const command = String((event.input as { command?: unknown }).command ?? ""); const reason = blockedCommandReason(command); if (!reason) return undefined; if (ctx.hasUI) ctx.ui.notify(`Blocked docker command: ${reason}`, "warning"); return { block: true, reason: `Blocked by packages/base/extensions/command-guard.ts: ${reason}.`, }; }); }