From 99510a1f75688ed387184fad515bd93fd1fc1e95 Mon Sep 17 00:00:00 2001
From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com>
Date: Sun, 10 May 2026 11:53:37 +0530
Subject: [PATCH] feat(coder/modules/boundary): add agent-firewall module
(#840)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
Extracts boundary installation and wrapper logic into a standalone
`coder/agent-firewall` module, decoupling it from `agentapi`.
### Why
Boundary is currently embedded inside `agentapi` (`scripts/boundary.sh`)
and duplicated in `claude-code`. This couples network isolation to the
AI/Tasks stack, but boundary is a general-purpose primitive — users
running a plain agent with no agentapi or tasks should be able to use it
too.
### What this adds
`registry/coder/modules/agent-firewall/` — a new first-class module
that:
* Installs boundary via one of three strategies:
1. `coder boundary` subcommand (default, zero-install)
2. Direct binary from release (`use_agent_firewall_directly = true`)
3. Compiled from source (`compile_agent_firewall_from_source = true`)
* Ships a comprehensive [default allowlist
config](registry/coder/modules/agent-firewall/config.yaml.tftpl)
(Anthropic, OpenAI, VCS, package managers, cloud platforms, etc.)
* Auto-fills the Coder deployment domain via
`data.coder_workspace.me.access_url`
* Supports inline config (`agent_firewall_config`) or external file
(`agent_firewall_config_path`), mutually exclusive with cross-variable
validation
* Creates a wrapper script at
`$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
* Strips `CAP_NET_ADMIN` from the coder binary (copies to
`coder-no-caps`) to allow execution inside network namespaces without
`sys_admin`
* Supports `pre_install_script` / `post_install_script` hooks
* Exposes `agent_firewall_wrapper_path`, `agent_firewall_config_path`,
and `scripts` outputs for script coordination
* No env vars exported — everything is output-only
### Usage
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
```
Works standalone with any agent — no agentapi dependency required.
### Testing
* 8 Terraform plan tests (`agent-firewall.tftest.hcl`): default outputs,
compile from source, use directly, custom hooks, custom module
directory, inline config, external config path, mutual exclusion
validation
* TypeScript integration tests (`main.test.ts`): state verification,
coder subcommand happy path, inline config, config path skip, custom
hooks, env var absence, wrapper execution, idempotent installation
## Type of Change
- [X] New module
## Module Information
**Path:** `registry/coder/modules/agent-firewall`
**New version:**
`v0.0.1`
**Breaking change:** No
## Related Issues
Closes coder/registry#844
🤖 Generated by Coder Agents
---------
Co-authored-by: Jay Kumar
---
.../coder/modules/agent-firewall/README.md | 146 +++++++
.../agent-firewall/agent-firewall.tftest.hcl | 157 ++++++++
.../modules/agent-firewall/config.yaml.tftpl | 218 ++++++++++
.../coder/modules/agent-firewall/main.test.ts | 376 ++++++++++++++++++
registry/coder/modules/agent-firewall/main.tf | 128 ++++++
.../agent-firewall/scripts/install.sh.tftpl | 131 ++++++
.../agent-firewall/testdata/coder-mock.sh | 38 ++
7 files changed, 1194 insertions(+)
create mode 100644 registry/coder/modules/agent-firewall/README.md
create mode 100644 registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl
create mode 100644 registry/coder/modules/agent-firewall/config.yaml.tftpl
create mode 100644 registry/coder/modules/agent-firewall/main.test.ts
create mode 100644 registry/coder/modules/agent-firewall/main.tf
create mode 100644 registry/coder/modules/agent-firewall/scripts/install.sh.tftpl
create mode 100644 registry/coder/modules/agent-firewall/testdata/coder-mock.sh
diff --git a/registry/coder/modules/agent-firewall/README.md b/registry/coder/modules/agent-firewall/README.md
new file mode 100644
index 00000000..42cd2b82
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/README.md
@@ -0,0 +1,146 @@
+---
+display_name: Agent Firewall
+description: Configures agent-firewall for network isolation in Coder workspaces
+icon: ../../../../.icons/coder.svg
+verified: true
+tags: [agent-firewall, ai, agents, firewall, boundary]
+---
+
+# Agent Firewall
+
+Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces.
+
+This module:
+
+- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source)
+- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
+- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable)
+- Provides the wrapper path, config path, and script names via outputs
+- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs
+
+```tf
+module "agent-firewall" {
+ source = "registry.coder.com/coder/agent-firewall/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.main.id
+}
+```
+
+## Examples
+
+Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation.
+
+### With Claude Code
+
+Use agent-firewall alongside the `claude-code` module to run Claude in a
+network-isolated environment.
+
+#### As an automated task
+
+```tf
+module "agent-firewall" {
+ source = "registry.coder.com/coder/agent-firewall/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.main.id
+}
+
+resource "coder_script" "claude_with_agent_firewall" {
+ agent_id = coder_agent.main.id
+ display_name = "Claude (Agent Firewall)"
+ run_on_start = true
+ script = <<-EOT
+ #!/bin/bash
+ set -e
+ coder exp sync want claude-agent-firewall \
+ ${join(" ", module.agent-firewall.scripts)} \
+ ${join(" ", module.claude-code.scripts)}
+ coder exp sync start claude-agent-firewall
+ "${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder"
+ EOT
+}
+```
+
+#### As a Coder app
+
+```tf
+module "agent-firewall" {
+ source = "registry.coder.com/coder/agent-firewall/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.main.id
+}
+
+resource "coder_app" "claude_with_agent_firewall" {
+ agent_id = coder_agent.main.id
+ display_name = "Claude Code"
+ slug = "claude-code"
+ command = <<-EOT
+ #!/bin/bash
+ set -e
+ exec tmux new-session -A -s claude-code \
+ '"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude'
+ EOT
+}
+```
+
+## Configuration
+
+The module ships with a comprehensive default config based on the
+[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services,
+OpenAI services, version control, package managers, container registries,
+cloud platforms, and common development tools.
+
+The Coder deployment domain is automatically added to the allowlist using
+`data.coder_workspace.me.access_url`.
+
+By default the config is written to
+`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can
+access the resolved path via the `agent_firewall_config_path` output. Override
+it in two ways:
+
+### Inline config
+
+Pass the full YAML content directly:
+
+```tf
+module "agent-firewall" {
+ source = "registry.coder.com/coder/agent-firewall/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.main.id
+
+ agent_firewall_config = <<-YAML
+ allowlist:
+ - domain=your-deployment.coder.com
+ - domain=api.anthropic.com
+ - domain=api.openai.com
+ log_dir: /tmp/agent_firewall_logs
+ proxy_port: 8087
+ log_level: warn
+ YAML
+}
+```
+
+### External config file
+
+Point to an existing config file in the workspace. The module will not
+write any config and the `agent_firewall_config_path` output will point to
+your path. The file must exist on disk before agent-firewall starts.
+
+```tf
+module "agent-firewall" {
+ source = "registry.coder.com/coder/agent-firewall/coder"
+ version = "0.0.1"
+ agent_id = coder_agent.main.id
+
+ agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml"
+}
+```
+
+> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually
+> exclusive, setting both produces a validation error.
+
+See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall)
+for the full config reference.
+
+## References
+
+- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall)
diff --git a/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl b/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl
new file mode 100644
index 00000000..3b4b0d9e
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl
@@ -0,0 +1,157 @@
+# Test for agent-firewall module
+
+run "plan_with_required_vars" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ }
+
+ # Verify the agent_firewall_wrapper_path output
+ assert {
+ condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
+ error_message = "agent_firewall_wrapper_path output should be correct"
+ }
+
+ # Verify agent_firewall_config_path output defaults to the managed path
+ assert {
+ condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
+ error_message = "agent_firewall_config_path output should default to managed config path"
+ }
+
+ # Verify the scripts output contains the install script name
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-install_script")
+ error_message = "scripts should contain the install script name"
+ }
+}
+
+run "plan_with_compile_from_source" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ compile_agent_firewall_from_source = true
+ agent_firewall_version = "main"
+ }
+
+ assert {
+ condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
+ error_message = "agent_firewall_wrapper_path output should be correct"
+ }
+
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-install_script")
+ error_message = "scripts should contain the install script name"
+ }
+}
+
+run "plan_with_use_directly" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ use_agent_firewall_directly = true
+ agent_firewall_version = "latest"
+ }
+
+ assert {
+ condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
+ error_message = "agent_firewall_wrapper_path output should be correct"
+ }
+
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-install_script")
+ error_message = "scripts should contain the install script name"
+ }
+}
+
+run "plan_with_custom_hooks" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ pre_install_script = "echo 'Before install'"
+ post_install_script = "echo 'After install'"
+ }
+
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-install_script")
+ error_message = "scripts should contain the install script name"
+ }
+
+ # Verify pre and post install script names are set
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-pre_install_script")
+ error_message = "scripts should contain the pre_install script name"
+ }
+
+ assert {
+ condition = contains(output.scripts, "coder-agent-firewall-post_install_script")
+ error_message = "scripts should contain the post_install script name"
+ }
+}
+
+run "plan_with_custom_module_directory" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ module_directory = "$HOME/.coder-modules/custom/agent-firewall"
+ }
+
+ assert {
+ condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh"
+ error_message = "agent_firewall_wrapper_path output should use custom module directory"
+ }
+
+ # Config path should also follow the module directory
+ assert {
+ condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml"
+ error_message = "agent_firewall_config_path output should use custom module directory"
+ }
+}
+
+run "plan_with_inline_config" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n"
+ }
+
+ # Inline config should still point to the managed path.
+ assert {
+ condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
+ error_message = "agent_firewall_config_path output should point to managed config path"
+ }
+}
+
+run "plan_with_config_path" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ agent_firewall_config_path = "/workspace/my-boundary-config.yaml"
+ }
+
+ # agent_firewall_config_path output should point to the user-provided path.
+ assert {
+ condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml"
+ error_message = "agent_firewall_config_path output should point to user-provided path"
+ }
+}
+
+run "plan_with_both_configs_should_fail" {
+ command = plan
+
+ variables {
+ agent_id = "test-agent-id"
+ agent_firewall_config = "allowlist: []"
+ agent_firewall_config_path = "/workspace/config.yaml"
+ }
+
+ expect_failures = [
+ var.agent_firewall_config,
+ ]
+}
diff --git a/registry/coder/modules/agent-firewall/config.yaml.tftpl b/registry/coder/modules/agent-firewall/config.yaml.tftpl
new file mode 100644
index 00000000..6ffea4a1
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/config.yaml.tftpl
@@ -0,0 +1,218 @@
+allowlist:
+ - domain=${CODER_DOMAIN}
+
+ # Anthropic Services
+ - domain=api.anthropic.com
+ - domain=statsig.anthropic.com
+ - domain=claude.ai
+
+ # OpenAI Services
+ - domain=api.openai.com
+ - domain=platform.openai.com
+ - domain=openai.com
+ - domain=chatgpt.com
+ - domain=*.oaiusercontent.com
+ - domain=*.oaistatic.com
+
+ # Version Control
+ - domain=github.com
+ - domain=www.github.com
+ - domain=api.github.com
+ - domain=raw.githubusercontent.com
+ - domain=objects.githubusercontent.com
+ - domain=codeload.github.com
+ - domain=avatars.githubusercontent.com
+ - domain=camo.githubusercontent.com
+ - domain=gist.github.com
+ - domain=gitlab.com
+ - domain=www.gitlab.com
+ - domain=registry.gitlab.com
+ - domain=bitbucket.org
+ - domain=www.bitbucket.org
+ - domain=api.bitbucket.org
+
+ # Container Registries
+ - domain=registry-1.docker.io
+ - domain=auth.docker.io
+ - domain=index.docker.io
+ - domain=hub.docker.com
+ - domain=www.docker.com
+ - domain=production.cloudflare.docker.com
+ - domain=download.docker.com
+ - domain=*.gcr.io
+ - domain=ghcr.io
+ - domain=mcr.microsoft.com
+ - domain=*.data.mcr.microsoft.com
+
+ # Cloud Platforms
+ - domain=cloud.google.com
+ - domain=accounts.google.com
+ - domain=gcloud.google.com
+ - domain=*.googleapis.com
+ - domain=storage.googleapis.com
+ - domain=compute.googleapis.com
+ - domain=container.googleapis.com
+ - domain=azure.com
+ - domain=portal.azure.com
+ - domain=microsoft.com
+ - domain=www.microsoft.com
+ - domain=*.microsoftonline.com
+ - domain=packages.microsoft.com
+ - domain=dotnet.microsoft.com
+ - domain=dot.net
+ - domain=visualstudio.com
+ - domain=dev.azure.com
+ - domain=oracle.com
+ - domain=www.oracle.com
+ - domain=java.com
+ - domain=www.java.com
+ - domain=java.net
+ - domain=www.java.net
+ - domain=download.oracle.com
+ - domain=yum.oracle.com
+
+ # Package Managers - JavaScript/Node
+ - domain=registry.npmjs.org
+ - domain=www.npmjs.com
+ - domain=www.npmjs.org
+ - domain=npmjs.com
+ - domain=npmjs.org
+ - domain=yarnpkg.com
+ - domain=registry.yarnpkg.com
+
+ # Package Managers - Python
+ - domain=pypi.org
+ - domain=www.pypi.org
+ - domain=files.pythonhosted.org
+ - domain=pythonhosted.org
+ - domain=test.pypi.org
+ - domain=pypi.python.org
+ - domain=pypa.io
+ - domain=www.pypa.io
+
+ # Package Managers - Ruby
+ - domain=rubygems.org
+ - domain=www.rubygems.org
+ - domain=api.rubygems.org
+ - domain=index.rubygems.org
+ - domain=ruby-lang.org
+ - domain=www.ruby-lang.org
+ - domain=rubyforge.org
+ - domain=www.rubyforge.org
+ - domain=rubyonrails.org
+ - domain=www.rubyonrails.org
+ - domain=rvm.io
+ - domain=get.rvm.io
+
+ # Package Managers - Rust
+ - domain=crates.io
+ - domain=www.crates.io
+ - domain=static.crates.io
+ - domain=rustup.rs
+ - domain=static.rust-lang.org
+ - domain=www.rust-lang.org
+
+ # Package Managers - Go
+ - domain=proxy.golang.org
+ - domain=sum.golang.org
+ - domain=index.golang.org
+ - domain=golang.org
+ - domain=www.golang.org
+ - domain=go.dev
+ - domain=dl.google.com
+ - domain=goproxy.io
+ - domain=pkg.go.dev
+
+ # Package Managers - JVM
+ - domain=maven.org
+ - domain=repo.maven.org
+ - domain=central.maven.org
+ - domain=repo1.maven.org
+ - domain=jcenter.bintray.com
+ - domain=gradle.org
+ - domain=www.gradle.org
+ - domain=services.gradle.org
+ - domain=spring.io
+ - domain=repo.spring.io
+
+ # Package Managers - Other Languages
+ - domain=packagist.org
+ - domain=www.packagist.org
+ - domain=repo.packagist.org
+ - domain=nuget.org
+ - domain=www.nuget.org
+ - domain=api.nuget.org
+ - domain=pub.dev
+ - domain=api.pub.dev
+ - domain=hex.pm
+ - domain=www.hex.pm
+ - domain=cpan.org
+ - domain=www.cpan.org
+ - domain=metacpan.org
+ - domain=www.metacpan.org
+ - domain=api.metacpan.org
+ - domain=cocoapods.org
+ - domain=www.cocoapods.org
+ - domain=cdn.cocoapods.org
+ - domain=haskell.org
+ - domain=www.haskell.org
+ - domain=hackage.haskell.org
+ - domain=swift.org
+ - domain=www.swift.org
+
+ # Linux Distributions
+ - domain=archive.ubuntu.com
+ - domain=security.ubuntu.com
+ - domain=ubuntu.com
+ - domain=www.ubuntu.com
+ - domain=*.ubuntu.com
+ - domain=ppa.launchpad.net
+ - domain=launchpad.net
+ - domain=www.launchpad.net
+
+ # Development Tools & Platforms
+ - domain=dl.k8s.io
+ - domain=pkgs.k8s.io
+ - domain=k8s.io
+ - domain=www.k8s.io
+ - domain=releases.hashicorp.com
+ - domain=apt.releases.hashicorp.com
+ - domain=rpm.releases.hashicorp.com
+ - domain=archive.releases.hashicorp.com
+ - domain=hashicorp.com
+ - domain=www.hashicorp.com
+ - domain=repo.anaconda.com
+ - domain=conda.anaconda.org
+ - domain=anaconda.org
+ - domain=www.anaconda.com
+ - domain=anaconda.com
+ - domain=continuum.io
+ - domain=apache.org
+ - domain=www.apache.org
+ - domain=archive.apache.org
+ - domain=downloads.apache.org
+ - domain=eclipse.org
+ - domain=www.eclipse.org
+ - domain=download.eclipse.org
+ - domain=nodejs.org
+ - domain=www.nodejs.org
+
+ # Cloud Services & Monitoring
+ - domain=statsig.com
+ - domain=www.statsig.com
+ - domain=api.statsig.com
+ - domain=*.sentry.io
+
+ # Content Delivery & Mirrors
+ - domain=*.sourceforge.net
+ - domain=packagecloud.io
+ - domain=*.packagecloud.io
+
+ # Schema & Configuration
+ - domain=json-schema.org
+ - domain=www.json-schema.org
+ - domain=json.schemastore.org
+ - domain=www.schemastore.org
+log_dir: ${BOUNDARY_LOG_DIR}
+log_level: warn
+proxy_port: 8087
diff --git a/registry/coder/modules/agent-firewall/main.test.ts b/registry/coder/modules/agent-firewall/main.test.ts
new file mode 100644
index 00000000..3b189fbb
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/main.test.ts
@@ -0,0 +1,376 @@
+import {
+ test,
+ afterEach,
+ describe,
+ setDefaultTimeout,
+ beforeAll,
+ expect,
+} from "bun:test";
+import {
+ execContainer,
+ readFileContainer,
+ runTerraformInit,
+ runTerraformApply,
+ testRequiredVariables,
+ runContainer,
+ removeContainer,
+} from "~test";
+import {
+ loadTestFile,
+ writeExecutable,
+ execModuleScript,
+ extractCoderEnvVars,
+} from "../agentapi/test-util";
+
+let cleanupFunctions: (() => Promise)[] = [];
+const registerCleanup = (cleanup: () => Promise) => {
+ cleanupFunctions.push(cleanup);
+};
+afterEach(async () => {
+ const cleanupFnsCopy = cleanupFunctions.slice().reverse();
+ cleanupFunctions = [];
+ for (const cleanup of cleanupFnsCopy) {
+ try {
+ await cleanup();
+ } catch (error) {
+ console.error("Error during cleanup:", error);
+ }
+ }
+});
+
+interface SetupProps {
+ moduleVariables?: Record;
+ skipCoderMock?: boolean;
+}
+
+const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall";
+const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`;
+const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`;
+
+const setup = async (
+ props?: SetupProps,
+): Promise<{ id: string; coderEnvVars: Record }> => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "foo",
+ ...props?.moduleVariables,
+ });
+
+ const coderEnvVars = extractCoderEnvVars(state);
+ const id = await runContainer("codercom/enterprise-node:latest");
+ registerCleanup(async () => {
+ await removeContainer(id);
+ });
+
+ await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]);
+
+ // Create a mock coder binary with boundary subcommand and exp sync support
+ if (!props?.skipCoderMock) {
+ await writeExecutable({
+ containerId: id,
+ filePath: "/usr/bin/coder",
+ content: await loadTestFile(import.meta.dir, "coder-mock.sh"),
+ });
+ }
+
+ // Extract ALL coder_scripts from the state (coder-utils creates multiple)
+ const allScripts = state.resources
+ .filter((r) => r.type === "coder_script")
+ .map((r) => ({
+ name: r.name,
+ script: r.instances[0].attributes.script as string,
+ }));
+
+ // Run scripts in lifecycle order
+ const executionOrder = [
+ "pre_install_script",
+ "install_script",
+ "post_install_script",
+ ];
+ const orderedScripts = executionOrder
+ .map((name) => allScripts.find((s) => s.name === name))
+ .filter((s): s is NonNullable => s != null);
+
+ // Write each script individually and create a combined runner
+ const scriptPaths: string[] = [];
+ for (const s of orderedScripts) {
+ const scriptPath = `/home/coder/${s.name}.sh`;
+ await writeExecutable({
+ containerId: id,
+ filePath: scriptPath,
+ content: s.script,
+ });
+ scriptPaths.push(scriptPath);
+ }
+
+ const combinedScript = [
+ "#!/bin/bash",
+ "set -o errexit",
+ "set -o pipefail",
+ ...scriptPaths.map((p) => `bash "${p}"`),
+ ].join("\n");
+
+ await writeExecutable({
+ containerId: id,
+ filePath: "/home/coder/script.sh",
+ content: combinedScript,
+ });
+
+ return { id, coderEnvVars };
+};
+
+setDefaultTimeout(60 * 1000);
+
+describe("agent-firewall", async () => {
+ beforeAll(async () => {
+ await runTerraformInit(import.meta.dir);
+ });
+
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "test-agent-id",
+ });
+
+ test("terraform-state-basic", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "test-agent-id",
+ });
+
+ const resources = state.resources;
+
+ // No coder_env resources should exist
+ const envResources = resources.filter((r) => r.type === "coder_env");
+ expect(envResources).toHaveLength(0);
+
+ // Verify no env vars are exported
+ const coderEnvVars = extractCoderEnvVars(state);
+ expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
+ expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
+
+ // Verify agent_firewall_config_path output
+ expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
+ "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
+ );
+
+ // Verify agent_firewall_wrapper_path output
+ expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe(
+ "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh",
+ );
+
+ // Verify scripts output contains install script
+ const scripts = state.outputs["scripts"]?.value as string[];
+ expect(scripts).toContain("coder-agent-firewall-install_script");
+ });
+
+ test("terraform-state-custom-module-directory", async () => {
+ const customDir = "$HOME/.coder-modules/custom/agent-firewall";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "test-agent-id",
+ module_directory: customDir,
+ });
+
+ // Verify output uses custom dir
+ const outputs = state.outputs;
+ expect(outputs["agent_firewall_wrapper_path"]?.value).toBe(
+ `${customDir}/scripts/agent-firewall-wrapper.sh`,
+ );
+ // Config path follows module directory
+ expect(outputs["agent_firewall_config_path"]?.value).toBe(
+ `${customDir}/config/config.yaml`,
+ );
+ });
+
+ test("terraform-state-inline-config", async () => {
+ const inlineConfig =
+ "allowlist:\n - domain=example.com\nlog_level: debug\n";
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "test-agent-id",
+ agent_firewall_config: inlineConfig,
+ });
+
+ // Inline config still writes to the managed path.
+ expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
+ "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
+ );
+ });
+
+ test("terraform-state-config-path", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "test-agent-id",
+ agent_firewall_config_path: "/workspace/my-config.yaml",
+ });
+
+ // agent_firewall_config_path output should point to the user-provided path.
+ expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
+ "/workspace/my-config.yaml",
+ );
+ });
+
+ test("happy-path-coder-subcommand", async () => {
+ const { id } = await setup();
+ await execModuleScript(id);
+
+ // Verify the wrapper script was created
+ const wrapperContent = await readFileContainer(id, WRAPPER_PATH);
+ expect(wrapperContent).toContain("#!/usr/bin/env bash");
+ expect(wrapperContent).toContain("coder-no-caps");
+ expect(wrapperContent).toContain("boundary");
+
+ // Verify the wrapper script is executable
+ const statResult = await execContainer(id, [
+ "stat",
+ "-c",
+ "%a",
+ WRAPPER_PATH,
+ ]);
+ expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/);
+
+ // Verify coder-no-caps binary was created
+ const coderNoCapsResult = await execContainer(id, [
+ "test",
+ "-f",
+ `${MODULE_DIR}/scripts/coder-no-caps`,
+ ]);
+ expect(coderNoCapsResult.exitCode).toBe(0);
+
+ // Verify default boundary config was written inside module directory
+ const configContent = await readFileContainer(id, CONFIG_PATH);
+ expect(configContent).toContain("allowlist:");
+ expect(configContent).toContain("domain=api.anthropic.com");
+ expect(configContent).toContain("domain=api.openai.com");
+ expect(configContent).toContain("proxy_port: 8087");
+
+ // Verify Coder domain was auto-filled from data.coder_workspace.me
+ // (the placeholder should be replaced with the actual deployment domain).
+ expect(configContent).not.toContain("domain=your-deployment.coder.com");
+
+ // Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME).
+ expect(configContent).toContain("log_dir: /home/coder/");
+ expect(configContent).not.toContain("$HOME");
+
+ // Check install log
+ const installLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/install.log`,
+ );
+ expect(installLog).toContain("Using coder boundary subcommand");
+ expect(installLog).toContain("Boundary config written to");
+ expect(installLog).toContain("boundary wrapper configured");
+ });
+
+ test("inline-config-written", async () => {
+ const customConfig =
+ "allowlist:\n - domain=custom.example.com\nlog_level: info\n";
+ const { id } = await setup({
+ moduleVariables: {
+ agent_firewall_config: customConfig,
+ },
+ });
+ await execModuleScript(id);
+
+ // Verify the inline config was written
+ const configContent = await readFileContainer(id, CONFIG_PATH);
+ expect(configContent).toContain("domain=custom.example.com");
+ expect(configContent).toContain("log_level: info");
+ });
+
+ test("config-path-skips-write", async () => {
+ const { id } = await setup({
+ moduleVariables: {
+ agent_firewall_config_path: "/workspace/external-config.yaml",
+ },
+ });
+ await execModuleScript(id);
+
+ // Verify NO config was written to the default path
+ const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]);
+ expect(checkResult.exitCode).not.toBe(0);
+
+ // Check install log confirms skip
+ const installLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/install.log`,
+ );
+ expect(installLog).toContain(
+ "Using external boundary config, skipping config write",
+ );
+ });
+
+ // Note: Tests for use_agent_firewall_directly and
+ // compile_agent_firewall_from_source are skipped because they require
+ // network access (downloading boundary) or compilation which are too
+ // slow for unit tests. These modes are tested manually.
+
+ test("custom-hooks", async () => {
+ const preInstallMarker = "pre-install-executed";
+ const postInstallMarker = "post-install-executed";
+
+ const { id } = await setup({
+ moduleVariables: {
+ pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`,
+ post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`,
+ },
+ });
+ await execModuleScript(id);
+
+ // Verify pre-install script ran
+ const preInstallLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/pre_install.log`,
+ );
+ expect(preInstallLog).toContain(preInstallMarker);
+
+ // Verify post-install script ran
+ const postInstallLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/post_install.log`,
+ );
+ expect(postInstallLog).toContain(postInstallMarker);
+
+ // Verify main install still ran
+ const installLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/install.log`,
+ );
+ expect(installLog).toContain("boundary wrapper configured");
+ });
+
+ test("no-env-vars", async () => {
+ const { coderEnvVars } = await setup();
+
+ // No env vars should be exported by this module.
+ expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
+ expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
+ });
+
+ test("wrapper-script-execution", async () => {
+ const { id } = await setup();
+ await execModuleScript(id);
+
+ // Try executing the wrapper script with a command
+ const wrapperResult = await execContainer(id, [
+ "bash",
+ "-c",
+ `${WRAPPER_PATH} echo boundary-test`,
+ ]);
+
+ // The wrapper passes the command directly to the boundary command
+ expect(wrapperResult.stdout).toContain("boundary-test");
+ });
+
+ test("installation-idempotency", async () => {
+ const { id } = await setup();
+
+ // Run the installation twice
+ await execModuleScript(id);
+ const firstInstallLog = await readFileContainer(
+ id,
+ `${MODULE_DIR}/logs/install.log`,
+ );
+
+ // Run again
+ const secondRun = await execModuleScript(id);
+ expect(secondRun.exitCode).toBe(0);
+
+ // Both runs should succeed
+ expect(firstInstallLog).toContain("boundary wrapper configured");
+ });
+});
diff --git a/registry/coder/modules/agent-firewall/main.tf b/registry/coder/modules/agent-firewall/main.tf
new file mode 100644
index 00000000..8e795007
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/main.tf
@@ -0,0 +1,128 @@
+terraform {
+ required_version = ">= 1.9"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.5"
+ }
+ }
+}
+
+data "coder_workspace" "me" {}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "agent_firewall_version" {
+ type = string
+ description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)."
+ default = "latest"
+}
+
+variable "compile_agent_firewall_from_source" {
+ type = bool
+ description = "Whether to compile agent-firewall from source instead of using the official install script."
+ default = false
+}
+
+variable "use_agent_firewall_directly" {
+ type = bool
+ description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release."
+ default = false
+}
+
+variable "agent_firewall_config" {
+ type = string
+ description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path."
+ default = null
+
+ validation {
+ condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null)
+ error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set."
+ }
+}
+
+variable "agent_firewall_config_path" {
+ type = string
+ description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config."
+ default = null
+}
+
+variable "pre_install_script" {
+ type = string
+ description = "Custom script to run before installing agent-firewall."
+ default = null
+}
+
+variable "post_install_script" {
+ type = string
+ description = "Custom script to run after installing agent-firewall."
+ default = null
+}
+
+variable "module_directory" {
+ type = string
+ description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall."
+ default = "$HOME/.coder-modules/coder/agent-firewall"
+}
+
+locals {
+ boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh"
+
+ # Extract domain from the Coder access URL for the default config
+ # allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com").
+ coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
+
+ # Config handling: resolve which config content to write and where
+ # agent_firewall_config_path output points to.
+ default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", {
+ CODER_DOMAIN = local.coder_domain
+ BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs"
+ })
+ boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config
+ boundary_config_dir = "${var.module_directory}/config"
+ boundary_config_file_path = "${local.boundary_config_dir}/config.yaml"
+ effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path
+ write_boundary_config = var.agent_firewall_config_path == null
+
+ install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
+ BOUNDARY_VERSION = var.agent_firewall_version
+ COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source)
+ USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly)
+ MODULE_DIR = var.module_directory
+ BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path
+ WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config)
+ BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : ""
+ BOUNDARY_CONFIG_DIR = local.boundary_config_dir
+ BOUNDARY_CONFIG_FILE = local.boundary_config_file_path
+ })
+}
+
+module "coder_utils" {
+ source = "registry.coder.com/coder/coder-utils/coder"
+ version = "0.0.1"
+ agent_id = var.agent_id
+ display_name_prefix = "Agent Firewall"
+ module_directory = var.module_directory
+ pre_install_script = var.pre_install_script
+ post_install_script = var.post_install_script
+ install_script = local.install_script
+}
+
+output "agent_firewall_wrapper_path" {
+ description = "Path to the agent-firewall wrapper script."
+ value = local.boundary_wrapper_path
+}
+
+output "agent_firewall_config_path" {
+ description = "Effective path to the agent-firewall config file."
+ value = local.effective_boundary_config_path
+}
+
+output "scripts" {
+ description = "List of script names for coder exp sync coordination."
+ value = module.coder_utils.scripts
+}
diff --git a/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl b/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl
new file mode 100644
index 00000000..6179e396
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl
@@ -0,0 +1,131 @@
+#!/bin/bash
+# Sets up boundary for network isolation in Coder workspaces.
+
+set -euo pipefail
+
+BOUNDARY_VERSION='${BOUNDARY_VERSION}'
+COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}'
+USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}'
+MODULE_DIR="${MODULE_DIR}"
+BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}"
+WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}'
+BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g")
+BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}"
+BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}"
+
+printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}"
+printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}"
+printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}"
+printf "MODULE_DIR: %s\n" "$${MODULE_DIR}"
+printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}"
+printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}"
+printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}"
+printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}"
+
+validate_boundary_subcommand() {
+ if ! command -v coder > /dev/null 2>&1; then
+ echo "Error: 'coder' command not found. boundary cannot be enabled." >&2
+ exit 1
+ fi
+
+ local output
+ echo "Checking for license"
+ if ! output=$(coder boundary 2>&1); then
+ if echo "$${output}" | grep -qi "license is not entitled"; then
+ echo "Error: your Coder deployment is not licensed for the boundary feature." >&2
+ echo "$${output}" >&2
+ echo "" >&2
+ exit 1
+ fi
+ fi
+}
+
+# Install boundary binary if needed.
+# Uses one of three strategies:
+# 1. Compile from source (compile_boundary_from_source=true)
+# 2. Install from release (use_boundary_directly=true)
+# 3. Use coder boundary subcommand (default, no installation needed)
+install_boundary() {
+ if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then
+ echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})"
+
+ # Remove existing boundary directory to allow re-running safely
+ if [[ -d boundary ]]; then
+ rm -rf boundary
+ fi
+
+ echo "Cloning boundary repository"
+ git clone https://github.com/coder/boundary.git
+ cd boundary || exit 1
+ git checkout "$${BOUNDARY_VERSION}"
+
+ make build
+
+ sudo cp boundary /usr/local/bin/
+ sudo chmod +x /usr/local/bin/boundary
+ cd - || exit 1
+ elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
+ echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})"
+ curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}"
+ else
+ validate_boundary_subcommand
+ echo "Using coder boundary subcommand (provided by Coder)"
+ fi
+}
+
+# Write boundary config file if the module is responsible for it.
+write_boundary_config() {
+ if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then
+ echo "Using external boundary config, skipping config write."
+ return 0
+ fi
+
+ mkdir -p "$${BOUNDARY_CONFIG_DIR}"
+ echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}"
+ echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}"
+}
+
+# Set up boundary: install, write config, create wrapper script.
+setup_boundary() {
+ echo "Setting up coder boundary..."
+
+ # Install boundary binary if needed
+ install_boundary
+
+ # Write boundary config
+ write_boundary_config
+
+ # Ensure the wrapper script directory exists.
+ mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")"
+
+ if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
+ # Use boundary binary directly (from compilation or release installation)
+ cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+exec boundary "$@"
+WRAPPER_EOF
+ else
+ # Use coder boundary subcommand (default)
+ # Copy coder binary to strip CAP_NET_ADMIN capabilities.
+ # This is necessary because boundary doesn't work with privileged binaries
+ # (you can't launch privileged binaries inside network namespaces unless
+ # you have sys_admin).
+ CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps"
+ if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then
+ echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2
+ exit 1
+ fi
+ cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)"
+exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@"
+WRAPPER_EOF
+ fi
+
+ chmod +x "$${BOUNDARY_WRAPPER_PATH}"
+ echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}"
+}
+
+setup_boundary
diff --git a/registry/coder/modules/agent-firewall/testdata/coder-mock.sh b/registry/coder/modules/agent-firewall/testdata/coder-mock.sh
new file mode 100644
index 00000000..89242004
--- /dev/null
+++ b/registry/coder/modules/agent-firewall/testdata/coder-mock.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Mock coder command for testing boundary module
+# Handles: coder boundary [--help | ]
+# Handles: coder exp sync [want|start|complete] (no-op for testing)
+
+# Handle exp sync commands (no-op for testing)
+if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then
+ exit 0
+fi
+
+if [[ "$1" == "boundary" ]]; then
+ shift
+
+ # Handle --help flag
+ if [[ "$1" == "--help" ]]; then
+ cat << 'EOF'
+boundary - Run commands in network isolation
+
+Usage:
+ coder boundary [flags] -- [args...]
+
+Examples:
+ coder boundary -- curl https://example.com
+ coder boundary -- npm install
+
+Flags:
+ -h, --help help for boundary
+EOF
+ exit 0
+ fi
+
+ # Execute the remaining arguments as a command
+ exec "$@"
+fi
+
+echo "Mock coder: Unknown command: $*"
+exit 1