Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: Go

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'

- name: Build
run: go build -v ./...

- name: Test
run: go test -v ./...
46 changes: 46 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: release

on:
push:
# run only against tags
tags:
- "*"

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- run: git fetch --force --tags
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: ">=1.24.0"
cache: true

- run: go generate -v ./...
- run: go vet -v ./...
- run: go test -v ./...

# https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
# agentjail uses Linux-only features (bwrap, iptables) so only Linux targets are built
- run: CGO_ENABLED=0 GOOS=linux GOARCH=386 go build -o agentjail_${{ github.ref_name }}_linux_386 ./cmd/agentjail
- run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o agentjail_${{ github.ref_name }}_linux_amd64 ./cmd/agentjail
- run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o agentjail_${{ github.ref_name }}_linux_arm64 ./cmd/agentjail

# create checksums.txt
- run: shasum -a 256 agentjail_* > checksums.txt

- name: Create a Release in a GitHub Action
uses: softprops/action-gh-release@v2
with:
files: |
agentjail_${{ github.ref_name }}_linux_386
agentjail_${{ github.ref_name }}_linux_amd64
agentjail_${{ github.ref_name }}_linux_arm64
checksums.txt
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.PHONY: build test lint tidy

build:
go build ./...

test:
go test -v -timeout 120s ./cmd/agentjail/... ./pkg/agentjail/...

lint:
go vet ./...

tidy:
go mod tidy
106 changes: 105 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,106 @@
# agentjail
Jail your agent.

**agentjail** runs an AI agent (or any command) inside a bubblewrap sandbox with its outbound network access restricted to an explicit allowlist of hostnames. All other network traffic — direct IP connections, private ranges, IPv6, cloud-metadata endpoints — is dropped.

## Why

AI coding agents need internet access to fetch documentation, call APIs, and run tools. Without constraints an agent (or a compromised tool it invokes) can exfiltrate data, reach internal services, or probe the local network. agentjail gives every agent run a private network namespace and a filtering HTTP proxy so it can only reach exactly the hosts you approve.

## How it works

```
┌─ host process ──────────────────────────────────────────────────────────┐
│ │
│ Go filtering proxy ──── Unix socket ←── socat TCP→Unix relay ─────┐ │
│ (enforces allowlist) (on shared fs) │ │
│ │ │
│ ┌─ bwrap sandbox (--unshare-net --unshare-pid) ─────────────────────┐│ │
│ │ iptables: OUTPUT DROP (except TCP 127.0.0.1:3128) ││ │
│ │ ip6tables: OUTPUT DROP ││ │
│ │ HTTP_PROXY=http://localhost:3128 ││ │
│ │ sudo -u nobody COMMAND ││ │
│ └───────────────────────────────────────────────────────────────────┘│ │
└─────────────────────────────────────────────────────────────────────────┘
```

1. **Filtering proxy** (`pkg/agentjail/tinyproxy.go`) — a Go HTTP/HTTPS proxy that listens on a Unix socket. It enforces the hostname allowlist and unconditionally blocks private/link-local IP ranges (RFC 1918, `169.254.0.0/16`, loopback, ULA, link-local IPv6).

2. **socat relay** (`pkg/agentjail/relay.go`) — bridges `127.0.0.1:3128` inside the sandbox to the proxy's Unix socket on the host filesystem. Using a Unix socket means the relay crosses the network namespace boundary via the shared bind-mount without any real network link. `socat` is chosen over language-runtime scripts (Python, Node …) to avoid any external interpreter dependency.

3. **Sandbox** (`pkg/agentjail/sandbox.go`) — `sudo bwrap --unshare-net --unshare-pid` creates the isolated environment. `iptables` drops all IPv4 outbound except TCP to loopback port 3128; `ip6tables` drops all IPv6. The command runs as `nobody` to prevent privilege escalation.

## Requirements

| Tool | Purpose |
|------|---------|
| `bwrap` (bubblewrap) | Linux user-namespace sandbox |
| `socat` | TCP → Unix socket relay |
| `sudo` | Root access to configure iptables inside the sandbox |
| `iptables` / `ip6tables` | Network filtering inside the sandbox |

Install on Debian/Ubuntu:

```sh
sudo apt-get install bubblewrap socat
```

## Installation

```sh
go install github.com/kitproj/agentjail/cmd/agentjail@latest
```

## Usage

```
agentjail [--allow PATTERN]... COMMAND [ARG]...
```

`PATTERN` is a Go regular expression matched against the destination hostname. Repeat `--allow` as many times as needed. If no `--allow` flags are given, all public destinations are permitted (private/link-local IPs are always blocked).

### Examples

Allow only `api.openai.com`:

```sh
agentjail --allow '^api\.openai\.com$' curl -sSf https://api.openai.com/v1/models
```

Allow a wildcard subdomain pattern:

```sh
agentjail \
--allow '^api\.openai\.com$' \
--allow '^([a-z0-9-]+\.)*openai\.com$' \
agent -f -p "summarise this file: README.md"
```

## Library API

```go
import "github.com/kitproj/agentjail/pkg/agentjail"

// allow is a list of Go regexps matched against the destination hostname.
// Pass nil to allow all public destinations.
err := agentjail.Run(ctx, allow, []string{"curl", "-sSf", "https://api.openai.com/v1/models"})
```

## Tests

```sh
make test
```

Security tests verify that the following bypass attempts are blocked:

- Direct IP access (bypassing DNS)
- DNS resolution of non-whitelisted domains
- IPv6 connections
- Cloud metadata endpoint (`169.254.169.254`)
- Local network probing (`192.168.x.x`, ping)
- Redirects to forbidden hosts
- Python raw socket escapes
- `netcat` probing
- `wget`, Perl `IO::Socket`, `dig` with explicit DNS server
- `sudo` privilege escalation from inside the sandbox

28 changes: 28 additions & 0 deletions cmd/agentjail/agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"errors"
"os/exec"
"testing"

"github.com/kitproj/agentjail/pkg/agentjail"
"github.com/stretchr/testify/require"
)

func TestAgentSayHello(t *testing.T) {
// Agent needs: cursor.com, *.cursor.com, *.cursor.sh, *.cursor.ai
allow := []string{
`^cursor\.com$`,
`^([a-z0-9-]+\.)*cursor\.com$`,
`^([a-z0-9-]+\.)*cursor\.sh$`,
`^([a-z0-9-]+\.)*cursor\.ai$`,
}
err := agentjail.Run(t.Context(), allow, []string{"bash", "-c", `agent -f -p "say hello"`})
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && (ee.ExitCode() == 1 || ee.ExitCode() == 127) {
t.Skip("agent failed (Cursor agent may be unavailable or not installed in this environment)")
}
require.NoError(t, err)
}
}
41 changes: 41 additions & 0 deletions cmd/agentjail/curl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"errors"
"os/exec"
"testing"

"github.com/kitproj/agentjail/pkg/agentjail"
"github.com/stretchr/testify/require"
)

func TestCurlAllowedHost(t *testing.T) {
// Verifies that an explicitly allowed host can be reached.
// github.com is widely resolvable; skip if DNS is unavailable.
err := agentjail.Run(t.Context(), []string{`^github\.com$`}, []string{"bash", "-c", "curl -sSf --connect-timeout 5 https://github.com"})
if err != nil {
var ee *exec.ExitError
// curl exit 6 = "Could not resolve host" – skip when host is unreachable.
if errors.As(err, &ee) && ee.ExitCode() == 6 {
t.Skip("github.com is not reachable in this environment")
}
}
require.NoError(t, err)
}

func TestCurlBlockedHost(t *testing.T) {
err := agentjail.Run(t.Context(), []string{`^example\.com$`}, []string{"bash", "-c", "curl -sSf --connect-timeout 5 https://google.com"})
require.Error(t, err)
}

func TestCurlAllowedHostDefault(t *testing.T) {
err := agentjail.Run(t.Context(), nil, []string{"bash", "-c", "curl -sSf https://httpbin.org/get"})
if err != nil {
var ee *exec.ExitError
// curl exit 6 = "Could not resolve host" – skip when the test host is unreachable.
if errors.As(err, &ee) && ee.ExitCode() == 6 {
t.Skip("httpbin.org is not reachable in this environment")
}
}
require.NoError(t, err)
}
13 changes: 13 additions & 0 deletions cmd/agentjail/escape_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package main

import (
"testing"

"github.com/kitproj/agentjail/pkg/agentjail"
"github.com/stretchr/testify/require"
)

func TestSudoEscapeFails(t *testing.T) {
err := agentjail.Run(t.Context(), nil, []string{"bash", "-c", "sudo -n true"})
require.Error(t, err)
}
10 changes: 10 additions & 0 deletions cmd/agentjail/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package main

import "fmt"

// multiFlag is a flag.Value that accumulates repeated --allow values into a
// string slice.
type multiFlag []string

func (m *multiFlag) String() string { return fmt.Sprint(*m) }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
42 changes: 42 additions & 0 deletions cmd/agentjail/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// agentjail launches a bwrap sandbox with full filesystem access but
// restricted network access via an allowlist.
//
// Usage:
//
// agentjail [--allow PATTERN]... COMMAND [ARG]...
//
// PATTERN is a Go regular expression matched against the destination hostname.
// Multiple --allow flags may be supplied. If no --allow flags are given all
// public destinations are permitted.
package main

import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"

"github.com/kitproj/agentjail/pkg/agentjail"
)

func main() {
var allow multiFlag
flag.Var(&allow, "allow", "hostname regex to allow (repeatable)")
flag.Parse()

args := flag.Args()
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "usage: agentjail [--allow PATTERN]... COMMAND [ARG]...")
os.Exit(1)
}

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()

if err := agentjail.Run(ctx, allow, args); err != nil {
fmt.Fprintf(os.Stderr, "agentjail: %v\n", err)
os.Exit(1)
}
}
Loading