From c5749ef8d4df8c9bf075ca44a069ec64cc9160fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:13:10 +0000 Subject: [PATCH 1/6] Initial plan From c7cc92ded0e3880bf57f5b4e03ff3f01d8ccd873 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:19:32 +0000 Subject: [PATCH 2/6] Add agentjail implementation: proxy, relay, sandbox, CLI and tests Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Agent-Logs-Url: https://github.com/kitproj/agentjail/sessions/0d0544bb-2185-4e3e-a698-8187bd9241db --- Makefile | 13 +++ cmd/agentjail/agent_test.go | 28 +++++ cmd/agentjail/curl_test.go | 39 +++++++ cmd/agentjail/escape_test.go | 13 +++ cmd/agentjail/main.go | 47 ++++++++ cmd/agentjail/security_test.go | 68 +++++++++++ go.mod | 11 ++ go.sum | 10 ++ pkg/agentjail/network_jail_test.go | 57 +++++++++ pkg/agentjail/relay.go | 90 ++++++++++++++ pkg/agentjail/run.go | 44 +++++++ pkg/agentjail/sandbox.go | 155 ++++++++++++++++++++++++ pkg/agentjail/tinyproxy.go | 181 +++++++++++++++++++++++++++++ 13 files changed, 756 insertions(+) create mode 100644 Makefile create mode 100644 cmd/agentjail/agent_test.go create mode 100644 cmd/agentjail/curl_test.go create mode 100644 cmd/agentjail/escape_test.go create mode 100644 cmd/agentjail/main.go create mode 100644 cmd/agentjail/security_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/agentjail/network_jail_test.go create mode 100644 pkg/agentjail/relay.go create mode 100644 pkg/agentjail/run.go create mode 100644 pkg/agentjail/sandbox.go create mode 100644 pkg/agentjail/tinyproxy.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cdc9c19 --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/agentjail/agent_test.go b/cmd/agentjail/agent_test.go new file mode 100644 index 0000000..d045955 --- /dev/null +++ b/cmd/agentjail/agent_test.go @@ -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) + } +} diff --git a/cmd/agentjail/curl_test.go b/cmd/agentjail/curl_test.go new file mode 100644 index 0000000..a6dd503 --- /dev/null +++ b/cmd/agentjail/curl_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + "os/exec" + "testing" + + "github.com/kitproj/agentjail/pkg/agentjail" + "github.com/stretchr/testify/require" +) + +func TestCurlGitHubIntuit(t *testing.T) { + err := agentjail.Run(t.Context(), []string{`^github\.intuit\.com$`}, []string{"bash", "-c", "curl -sSf https://github.intuit.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.intuit.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) +} diff --git a/cmd/agentjail/escape_test.go b/cmd/agentjail/escape_test.go new file mode 100644 index 0000000..5f78887 --- /dev/null +++ b/cmd/agentjail/escape_test.go @@ -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) +} diff --git a/cmd/agentjail/main.go b/cmd/agentjail/main.go new file mode 100644 index 0000000..c80693d --- /dev/null +++ b/cmd/agentjail/main.go @@ -0,0 +1,47 @@ +// 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" +) + +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 } + +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) + } +} diff --git a/cmd/agentjail/security_test.go b/cmd/agentjail/security_test.go new file mode 100644 index 0000000..5db7753 --- /dev/null +++ b/cmd/agentjail/security_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "testing" + + "github.com/kitproj/agentjail/pkg/agentjail" + "github.com/stretchr/testify/require" +) + +func TestDNSResolutionBlocked(t *testing.T) { + err := agentjail.Run(t.Context(), []string{`^allowed\.com$`}, []string{"nslookup", "google.com"}) + require.Error(t, err, "DNS resolution for unauthorized domains should fail") +} + +func TestDirectIPBypass(t *testing.T) { + err := agentjail.Run(t.Context(), []string{`^google\.com$`}, []string{"curl", "-sSf", "--connect-timeout", "2", "http://1.1.1.1"}) + require.Error(t, err, "Direct IP access should be blocked if not explicitly allowed") +} + +func TestIPv6Bypass(t *testing.T) { + err := agentjail.Run(t.Context(), nil, []string{"bash", "-c", "curl -6 -sSf --connect-timeout 2 https://google.com"}) + require.Error(t, err, "IPv6 connections should be subject to the same jail rules") +} + +func TestCloudMetadataBlocked(t *testing.T) { + err := agentjail.Run(t.Context(), nil, []string{"curl", "-sSf", "--connect-timeout", "1", "http://169.254.169.254/latest/meta-data/"}) + require.Error(t, err, "Cloud metadata services should be strictly blocked") +} + +func TestLocalNetworkProbing(t *testing.T) { + err := agentjail.Run(t.Context(), nil, []string{"ping", "-c", "1", "192.168.1.1"}) + require.Error(t, err, "Pinging local gateway should be blocked") +} + +func TestRedirectFollowToForbidden(t *testing.T) { + err := agentjail.Run(t.Context(), []string{`^httpbin\.org$`}, []string{"curl", "-L", "-sSf", "https://httpbin.org/redirect-to?url=https://google.com"}) + require.Error(t, err, "Following a redirect to a forbidden host should be blocked at the network level") +} + +func TestPythonSocketEscape(t *testing.T) { + cmd := []string{"python3", "-c", "import socket; s=socket.socket(); s.settimeout(2); s.connect(('8.8.8.8', 53))"} + err := agentjail.Run(t.Context(), nil, cmd) + require.Error(t, err, "Python raw socket connections should be blocked") +} + +func TestNetcatProbe(t *testing.T) { + err := agentjail.Run(t.Context(), nil, []string{"nc", "-zv", "8.8.8.8", "443"}) + require.Error(t, err, "Netcat probing should be blocked") +} + +func TestTableBypassSuite(t *testing.T) { + tests := []struct { + name string + allow []string + command []string + }{ + {"Wget Check", nil, []string{"wget", "-qO-", "--timeout=2", "https://google.com"}}, + {"Perl Socket", nil, []string{"perl", "-MIO::Socket", "-e", "IO::Socket::INET->new('8.8.8.8:80') or exit(1)"}}, + {"Dig DNS", nil, []string{"dig", "@8.8.8.8", "google.com"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := agentjail.Run(t.Context(), tc.allow, tc.command) + require.Error(t, err, "Expected %s to be blocked", tc.name) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3918b0a --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/kitproj/agentjail + +go 1.24 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/agentjail/network_jail_test.go b/pkg/agentjail/network_jail_test.go new file mode 100644 index 0000000..1eb20e0 --- /dev/null +++ b/pkg/agentjail/network_jail_test.go @@ -0,0 +1,57 @@ +package agentjail_test + +import ( + "testing" + + "github.com/kitproj/agentjail/pkg/agentjail" + "github.com/stretchr/testify/require" +) + +func TestBlockedByDefault(t *testing.T) { + // With an explicit allowlist that doesn't include the target, traffic must fail. + err := agentjail.Run(t.Context(), []string{`^allowed\.example\.com$`}, []string{ + "bash", "-c", "curl -sSf --connect-timeout 2 https://github.com", + }) + require.Error(t, err, "connection to non-whitelisted host should be blocked") +} + +func TestDirectIPBlocked(t *testing.T) { + // Direct IP connections should be blocked by iptables even when the + // allowlist is empty (allow-all public mode). + err := agentjail.Run(t.Context(), nil, []string{ + "bash", "-c", "nc -z -w 2 8.8.8.8 53", + }) + require.Error(t, err, "direct IP connection should be blocked by iptables") +} + +func TestPrivateIPAlwaysBlocked(t *testing.T) { + // Private / link-local IPs are always blocked by the proxy regardless of + // the allowlist. + err := agentjail.Run(t.Context(), nil, []string{ + "curl", "-sSf", "--connect-timeout", "2", "http://192.168.0.1/", + }) + require.Error(t, err, "private IP should always be blocked") +} + +func TestIsPrivateAddr(t *testing.T) { + // Internal unit tests for the private-address helper – no network required. + for _, tc := range []struct { + addr string + private bool + }{ + {"127.0.0.1", true}, + {"10.0.0.1", true}, + {"172.16.5.1", true}, + {"192.168.1.1", true}, + {"169.254.169.254", true}, + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"::1", true}, + {"fe80::1", true}, + {"2001:db8::1", false}, + } { + t.Run(tc.addr, func(t *testing.T) { + require.Equal(t, tc.private, agentjail.IsPrivateAddr(tc.addr)) + }) + } +} diff --git a/pkg/agentjail/relay.go b/pkg/agentjail/relay.go new file mode 100644 index 0000000..ffe94e8 --- /dev/null +++ b/pkg/agentjail/relay.go @@ -0,0 +1,90 @@ +package agentjail + +import ( + "fmt" + "os" + "path/filepath" +) + +// relayScript is a Python 3 program embedded as a string. +// When executed as: +// +// python3 relay.py +// +// it listens on 127.0.0.1: and forwards every accepted TCP +// connection to the Unix-domain socket at . When the +// listening socket is bound and ready it creates so the +// caller can synchronise. +// +// This allows a sandboxed process (with a private network namespace) to +// reach the host-side filtering proxy via the shared filesystem without +// any physical network link between the namespaces. +const relayScript = `#!/usr/bin/env python3 +import socket, threading, sys, os + +unix_sock_path = sys.argv[1] +tcp_port = int(sys.argv[2]) +ready_path = sys.argv[3] + +server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +server.bind(('127.0.0.1', tcp_port)) +server.listen(100) + +with open(ready_path, 'w') as f: + f.write('') + +def pipe(src, dst): + try: + while True: + data = src.recv(8192) + if not data: + break + dst.sendall(data) + except Exception: + pass + finally: + try: + src.close() + except Exception: + pass + try: + dst.close() + except Exception: + pass + +def handle(client): + try: + unix = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + unix.connect(unix_sock_path) + t1 = threading.Thread(target=pipe, args=(client, unix), daemon=True) + t2 = threading.Thread(target=pipe, args=(unix, client), daemon=True) + t1.start() + t2.start() + t1.join() + t2.join() + except Exception: + pass + finally: + try: + client.close() + except Exception: + pass + +try: + while True: + client, _ = server.accept() + threading.Thread(target=handle, args=(client,), daemon=True).start() +except Exception: + pass +` + +// writeRelayScript writes the embedded Python relay script into dir and +// returns the full path of the created file. +func writeRelayScript(dir string) (string, error) { + path := filepath.Join(dir, "relay.py") + if err := os.WriteFile(path, []byte(relayScript), 0o755); err != nil { + return "", fmt.Errorf("write relay script: %w", err) + } + return path, nil +} diff --git a/pkg/agentjail/run.go b/pkg/agentjail/run.go new file mode 100644 index 0000000..d9a2cbe --- /dev/null +++ b/pkg/agentjail/run.go @@ -0,0 +1,44 @@ +package agentjail + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +// Run executes command inside a network-isolated sandbox. +// +// allow is a list of Go regular expressions matched against the destination +// hostname. A nil or empty list permits all public destinations. Private +// and link-local IP ranges are always blocked regardless of allow. +// +// The sandbox uses bubblewrap (bwrap) to create a new network namespace and +// iptables rules that allow outbound traffic only through an HTTP proxy +// (running in the parent process) which enforces the allowlist. +// +// bwrap and sudo must be available on the host; passwordless sudo is required. +func Run(ctx context.Context, allow []string, command []string) error { + if len(command) == 0 { + return fmt.Errorf("no command specified") + } + + dir, err := os.MkdirTemp("", "agentjail-proxy-*") + if err != nil { + return fmt.Errorf("create proxy dir: %w", err) + } + defer os.RemoveAll(dir) + + sockPath := filepath.Join(dir, "proxy.sock") + + // Use a child context so we can shut down the proxy goroutine as soon + // as the sandbox exits, regardless of the parent context's lifetime. + proxyCtx, cancelProxy := context.WithCancel(ctx) + defer cancelProxy() + + if err := startProxy(proxyCtx, allow, sockPath); err != nil { + return fmt.Errorf("start proxy: %w", err) + } + + return runInSandbox(ctx, sockPath, command) +} diff --git a/pkg/agentjail/sandbox.go b/pkg/agentjail/sandbox.go new file mode 100644 index 0000000..274ecf8 --- /dev/null +++ b/pkg/agentjail/sandbox.go @@ -0,0 +1,155 @@ +package agentjail + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const proxyPort = 3128 + +// runInSandbox executes command inside a bubblewrap sandbox that has: +// - A private network namespace (--unshare-net) +// - A private PID namespace (--unshare-pid) so all processes die when bwrap exits +// - Full read-write access to the host filesystem (--bind / /) +// - iptables rules that permit only TCP traffic to 127.0.0.1:3128 +// - IPv6 fully blocked via ip6tables +// - A Python TCP→Unix relay bridging 127.0.0.1:3128 to sockPath +// - HTTP_PROXY / HTTPS_PROXY pointed at the relay +// - The command itself executed as the unprivileged user "nobody" +// +// sockPath is the Unix socket on which the host-side filtering proxy listens. +func runInSandbox(ctx context.Context, sockPath string, command []string) error { + dir, err := os.MkdirTemp("", "agentjail-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(dir) + + relayPath, err := writeRelayScript(dir) + if err != nil { + return err + } + + readyPath := filepath.Join(dir, "ready") + + // Build the inner shell script that runs inside bwrap. + // Steps: + // 1. Start the Python relay in the background (as root, before iptables). + // 2. Wait for the relay's ready-file (up to 5 s). + // 3. Apply iptables / ip6tables rules. + // 4. Execute the command as user "nobody" with proxy env vars. + inner := fmt.Sprintf(`set -e +# Start the TCP→Unix relay in background; redirect its output to /dev/null so +# it does not hold our stdout/stderr pipes open after bwrap tears down. +python3 %s %s %d %s >/dev/null 2>&1 & +RELAY_PID=$! +READY=0 +for i in $(seq 1 100); do + if [ -f %s ]; then READY=1; break; fi + sleep 0.05 +done +if [ "$READY" -eq 0 ]; then + echo "agentjail: relay did not start" >&2 + kill -9 $RELAY_PID 2>/dev/null || true + exit 1 +fi + +# IPv4: allow only TCP to/from the proxy port on loopback; drop everything else. +iptables -F 2>/dev/null || true +iptables -P OUTPUT DROP 2>/dev/null || true +iptables -A OUTPUT -d 127.0.0.0/8 -p tcp -m tcp --dport %d -j ACCEPT 2>/dev/null || true +iptables -A OUTPUT -d 127.0.0.0/8 -p tcp -m tcp --sport %d -j ACCEPT 2>/dev/null || true + +# IPv6: block everything. +ip6tables -F 2>/dev/null || true +ip6tables -P OUTPUT DROP 2>/dev/null || true + +# Run the command as an unprivileged user so that sudo escapes fail. +HTTP_PROXY=http://localhost:%d \ +HTTPS_PROXY=http://localhost:%d \ +http_proxy=http://localhost:%d \ +https_proxy=http://localhost:%d \ +NO_PROXY='' \ +no_proxy='' \ +HOME=/tmp \ +sudo -u nobody %s +EXIT=$? +kill -9 $RELAY_PID 2>/dev/null || true +exit $EXIT +`, + relayPath, sockPath, proxyPort, readyPath, + readyPath, + proxyPort, proxyPort, + proxyPort, proxyPort, proxyPort, proxyPort, + buildCmdLine(command), + ) + + bwrap, err := exec.LookPath("bwrap") + if err != nil { + return fmt.Errorf("bwrap not found: %w", err) + } + + args := []string{ + bwrap, + "--bind", "/", "/", + "--proc", "/proc", + "--dev", "/dev", + "--unshare-net", + "--unshare-pid", // private PID namespace: all procs die when bwrap exits + "bash", "-c", inner, + } + + cmd := exec.CommandContext(ctx, "sudo", args...) + + // Capture output to a buffer so that lingering child processes (e.g. sudo + // PAM helpers) that inherit our stdout/stderr do not keep go test's output + // pipe open indefinitely. We write the buffered output to os.Stderr after + // the command returns to preserve visibility without blocking on the pipe. + var outBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &outBuf + + // After the main command exits, allow up to 3 s for I/O to drain before + // abandoning any lingering child processes. + cmd.WaitDelay = 3 * time.Second + + runErr := cmd.Run() + + // Always flush captured output so users can see what happened. + if outBuf.Len() > 0 { + os.Stderr.Write(outBuf.Bytes()) + } + + // If WaitDelay fired but the main process already exited, use its exit + // status as the authoritative result rather than the WaitDelay error. + if errors.Is(runErr, exec.ErrWaitDelay) && cmd.ProcessState != nil { + if cmd.ProcessState.ExitCode() != 0 { + return fmt.Errorf("command exited with code %d", cmd.ProcessState.ExitCode()) + } + return nil + } + return runErr +} + +// buildCmdLine returns a shell-safe command line string for the given args. +func buildCmdLine(args []string) string { + escaped := make([]string, len(args)) + for i, a := range args { + escaped[i] = shellescape(a) + } + return strings.Join(escaped, " ") +} + +// shellescape wraps s in single quotes, escaping any single-quote characters +// already present in s. +func shellescape(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + diff --git a/pkg/agentjail/tinyproxy.go b/pkg/agentjail/tinyproxy.go new file mode 100644 index 0000000..61f1b96 --- /dev/null +++ b/pkg/agentjail/tinyproxy.go @@ -0,0 +1,181 @@ +// Package agentjail provides a sandboxed execution environment with +// network access restricted via an allowlist of domain patterns. +package agentjail + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "regexp" + "strings" +) + +// proxy is an HTTP/HTTPS proxy that enforces a domain allowlist. +// It listens on a Unix socket so it can be shared with sandboxed processes. +type proxy struct { + allow []*regexp.Regexp + ln net.Listener +} + +// startProxy starts the filtering HTTP proxy listening on sockPath. +// allow is a list of Go regular expressions; a nil/empty list allows all +// public destinations. Private/link-local addresses are always blocked. +func startProxy(ctx context.Context, allow []string, sockPath string) error { + p := &proxy{} + for _, pattern := range allow { + re, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid allow pattern %q: %w", pattern, err) + } + p.allow = append(p.allow, re) + } + + ln, err := net.Listen("unix", sockPath) + if err != nil { + return fmt.Errorf("proxy listen: %w", err) + } + p.ln = ln + + go func() { + <-ctx.Done() + ln.Close() + }() + + go p.serve() + return nil +} + +func (p *proxy) serve() { + for { + conn, err := p.ln.Accept() + if err != nil { + return + } + go p.handle(conn) + } +} + +// isAllowed reports whether the destination host (host:port or bare host) is +// permitted by the allowlist and is not a private/reserved address. +func (p *proxy) isAllowed(hostPort string) bool { + host, _, err := net.SplitHostPort(hostPort) + if err != nil { + host = hostPort + } + + // Always block private / link-local destinations regardless of the list. + if isPrivateAddr(host) { + return false + } + + // Empty allowlist → allow everything (public). + if len(p.allow) == 0 { + return true + } + + for _, re := range p.allow { + if re.MatchString(host) { + return true + } + } + return false +} + +func (p *proxy) handle(clientConn net.Conn) { + defer clientConn.Close() + + req, err := http.ReadRequest(bufio.NewReader(clientConn)) + if err != nil { + return + } + + if req.Method == http.MethodConnect { + // HTTPS tunnel (CONNECT method) + host := req.Host + if !p.isAllowed(host) { + fmt.Fprintf(clientConn, "HTTP/1.1 403 Forbidden\r\n\r\n") + return + } + + serverConn, err := net.Dial("tcp", host) + if err != nil { + fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + } + defer serverConn.Close() + + fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + + go io.Copy(serverConn, clientConn) + io.Copy(clientConn, serverConn) + return + } + + // Plain HTTP proxy + host := req.Host + if host == "" { + host = req.URL.Host + } + if !strings.Contains(host, ":") { + host += ":80" + } + + if !p.isAllowed(host) { + fmt.Fprintf(clientConn, "HTTP/1.1 403 Forbidden\r\n\r\n") + return + } + + serverConn, err := net.Dial("tcp", host) + if err != nil { + fmt.Fprintf(clientConn, "HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + } + defer serverConn.Close() + + if err := req.WriteProxy(serverConn); err != nil { + return + } + + resp, err := http.ReadResponse(bufio.NewReader(serverConn), req) + if err != nil { + return + } + defer resp.Body.Close() + resp.Write(clientConn) +} + +// IsPrivateAddr reports whether addr is a loopback, private, or link-local IP. +func IsPrivateAddr(addr string) bool { + return isPrivateAddr(addr) +} + +// isPrivateAddr reports whether addr is a loopback, private, or link-local IP. +func isPrivateAddr(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil { + return false + } + privateRanges := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", // link-local / cloud metadata + "127.0.0.0/8", + "::1/128", + "fe80::/10", + "fc00::/7", + } + for _, cidr := range privateRanges { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if network.Contains(ip) { + return true + } + } + return false +} From bfed3b40f1fd747f8447038a04c048b63261aaae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 00:22:19 +0000 Subject: [PATCH 3/6] Fix proxy bufio.Reader reuse and trailing blank line (code review) Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Agent-Logs-Url: https://github.com/kitproj/agentjail/sessions/0d0544bb-2185-4e3e-a698-8187bd9241db --- pkg/agentjail/sandbox.go | 1 - pkg/agentjail/tinyproxy.go | 11 +++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/pkg/agentjail/sandbox.go b/pkg/agentjail/sandbox.go index 274ecf8..2f8f9cd 100644 --- a/pkg/agentjail/sandbox.go +++ b/pkg/agentjail/sandbox.go @@ -152,4 +152,3 @@ func buildCmdLine(args []string) string { func shellescape(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } - diff --git a/pkg/agentjail/tinyproxy.go b/pkg/agentjail/tinyproxy.go index 61f1b96..221ced4 100644 --- a/pkg/agentjail/tinyproxy.go +++ b/pkg/agentjail/tinyproxy.go @@ -87,7 +87,11 @@ func (p *proxy) isAllowed(hostPort string) bool { func (p *proxy) handle(clientConn net.Conn) { defer clientConn.Close() - req, err := http.ReadRequest(bufio.NewReader(clientConn)) + // Create a single buffered reader for this connection so that data + // buffered while reading the request is not lost when tunnelling. + clientReader := bufio.NewReader(clientConn) + + req, err := http.ReadRequest(clientReader) if err != nil { return } @@ -109,7 +113,9 @@ func (p *proxy) handle(clientConn net.Conn) { fmt.Fprintf(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") - go io.Copy(serverConn, clientConn) + // Use clientReader (not clientConn) so that any data already buffered + // by the reader is forwarded to the server before new reads proceed. + go io.Copy(serverConn, clientReader) io.Copy(clientConn, serverConn) return } @@ -139,6 +145,7 @@ func (p *proxy) handle(clientConn net.Conn) { return } + // One response per forwarded request; a fresh reader is correct here. resp, err := http.ReadResponse(bufio.NewReader(serverConn), req) if err != nil { return From 878dd478c3e221bb4d9fa2defd25d862e6438a98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:23:26 +0000 Subject: [PATCH 4/6] Address review: replace Python relay with socat, remove Intuit, move multiFlag, add README Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Agent-Logs-Url: https://github.com/kitproj/agentjail/sessions/19f1c869-524d-452a-b2e4-4929211a24f3 --- README.md | 106 ++++++++++++++++++++++++++++++++++++- cmd/agentjail/curl_test.go | 8 +-- cmd/agentjail/flags.go | 10 ++++ cmd/agentjail/main.go | 5 -- pkg/agentjail/relay.go | 102 ++++++++--------------------------- pkg/agentjail/sandbox.go | 60 +++++++++------------ 6 files changed, 169 insertions(+), 122 deletions(-) create mode 100644 cmd/agentjail/flags.go diff --git a/README.md b/README.md index 75158d8..bf584fa 100644 --- a/README.md +++ b/README.md @@ -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 + diff --git a/cmd/agentjail/curl_test.go b/cmd/agentjail/curl_test.go index a6dd503..e6df418 100644 --- a/cmd/agentjail/curl_test.go +++ b/cmd/agentjail/curl_test.go @@ -9,13 +9,15 @@ import ( "github.com/stretchr/testify/require" ) -func TestCurlGitHubIntuit(t *testing.T) { - err := agentjail.Run(t.Context(), []string{`^github\.intuit\.com$`}, []string{"bash", "-c", "curl -sSf https://github.intuit.com"}) +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.intuit.com is not reachable in this environment") + t.Skip("github.com is not reachable in this environment") } } require.NoError(t, err) diff --git a/cmd/agentjail/flags.go b/cmd/agentjail/flags.go new file mode 100644 index 0000000..27a0fb0 --- /dev/null +++ b/cmd/agentjail/flags.go @@ -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 } diff --git a/cmd/agentjail/main.go b/cmd/agentjail/main.go index c80693d..0022827 100644 --- a/cmd/agentjail/main.go +++ b/cmd/agentjail/main.go @@ -21,11 +21,6 @@ import ( "github.com/kitproj/agentjail/pkg/agentjail" ) -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 } - func main() { var allow multiFlag flag.Var(&allow, "allow", "hostname regex to allow (repeatable)") diff --git a/pkg/agentjail/relay.go b/pkg/agentjail/relay.go index ffe94e8..17e0985 100644 --- a/pkg/agentjail/relay.go +++ b/pkg/agentjail/relay.go @@ -2,89 +2,33 @@ package agentjail import ( "fmt" - "os" - "path/filepath" + "os/exec" ) -// relayScript is a Python 3 program embedded as a string. -// When executed as: +// startRelay launches a socat process that forwards TCP connections on +// 127.0.0.1:port to the Unix-domain socket at sockPath. // -// python3 relay.py +// socat is used instead of a language-runtime relay (Python, Node, etc.) to +// avoid any dependency on an external interpreter. It is part of standard +// package repositories on all major Linux distributions. // -// it listens on 127.0.0.1: and forwards every accepted TCP -// connection to the Unix-domain socket at . When the -// listening socket is bound and ready it creates so the -// caller can synchronise. -// -// This allows a sandboxed process (with a private network namespace) to -// reach the host-side filtering proxy via the shared filesystem without -// any physical network link between the namespaces. -const relayScript = `#!/usr/bin/env python3 -import socket, threading, sys, os - -unix_sock_path = sys.argv[1] -tcp_port = int(sys.argv[2]) -ready_path = sys.argv[3] - -server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -server.bind(('127.0.0.1', tcp_port)) -server.listen(100) - -with open(ready_path, 'w') as f: - f.write('') - -def pipe(src, dst): - try: - while True: - data = src.recv(8192) - if not data: - break - dst.sendall(data) - except Exception: - pass - finally: - try: - src.close() - except Exception: - pass - try: - dst.close() - except Exception: - pass - -def handle(client): - try: - unix = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - unix.connect(unix_sock_path) - t1 = threading.Thread(target=pipe, args=(client, unix), daemon=True) - t2 = threading.Thread(target=pipe, args=(unix, client), daemon=True) - t1.start() - t2.start() - t1.join() - t2.join() - except Exception: - pass - finally: - try: - client.close() - except Exception: - pass - -try: - while True: - client, _ = server.accept() - threading.Thread(target=handle, args=(client,), daemon=True).start() -except Exception: - pass -` +// The caller is responsible for terminating the returned *exec.Cmd when the +// relay is no longer needed. +func startRelay(sockPath string, port int) (*exec.Cmd, error) { + socat, err := exec.LookPath("socat") + if err != nil { + return nil, fmt.Errorf("socat not found (see README for installation instructions): %w", err) + } -// writeRelayScript writes the embedded Python relay script into dir and -// returns the full path of the created file. -func writeRelayScript(dir string) (string, error) { - path := filepath.Join(dir, "relay.py") - if err := os.WriteFile(path, []byte(relayScript), 0o755); err != nil { - return "", fmt.Errorf("write relay script: %w", err) + // TCP-LISTEN: binds 127.0.0.1:port immediately before the first accept, + // so the port is ready as soon as socat starts (no separate readiness poll + // needed; a brief sleep is sufficient for the socket to be bound). + cmd := exec.Command(socat, + fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr,bind=127.0.0.1", port), + fmt.Sprintf("UNIX-CONNECT:%s", sockPath), + ) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start relay: %w", err) } - return path, nil + return cmd, nil } diff --git a/pkg/agentjail/sandbox.go b/pkg/agentjail/sandbox.go index 2f8f9cd..68b8946 100644 --- a/pkg/agentjail/sandbox.go +++ b/pkg/agentjail/sandbox.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "time" ) @@ -20,47 +19,44 @@ const proxyPort = 3128 // - Full read-write access to the host filesystem (--bind / /) // - iptables rules that permit only TCP traffic to 127.0.0.1:3128 // - IPv6 fully blocked via ip6tables -// - A Python TCP→Unix relay bridging 127.0.0.1:3128 to sockPath +// - A socat TCP→Unix relay bridging 127.0.0.1:3128 to sockPath // - HTTP_PROXY / HTTPS_PROXY pointed at the relay // - The command itself executed as the unprivileged user "nobody" // // sockPath is the Unix socket on which the host-side filtering proxy listens. func runInSandbox(ctx context.Context, sockPath string, command []string) error { - dir, err := os.MkdirTemp("", "agentjail-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - defer os.RemoveAll(dir) - - relayPath, err := writeRelayScript(dir) + // Start the socat relay on the host before entering the sandbox. + // socat binds the TCP port synchronously before handling the first + // connection, so relaySyncDelay is enough to ensure it is ready. + relay, err := startRelay(sockPath, proxyPort) if err != nil { return err } + defer func() { + // Ask socat to shut down gracefully; fall back to SIGKILL if needed. + if relay.Process != nil { + relay.Process.Signal(os.Interrupt) //nolint:errcheck + done := make(chan struct{}) + go func() { relay.Wait(); close(done) }() //nolint:errcheck + select { + case <-done: + case <-time.After(2 * time.Second): + relay.Process.Kill() //nolint:errcheck + } + } + }() - readyPath := filepath.Join(dir, "ready") + // relaySyncDelay is the time we wait after starting socat before entering + // the sandbox. socat binds its listening socket synchronously during + // startup, so this short pause is sufficient for the port to be ready. + const relaySyncDelay = 100 * time.Millisecond + time.Sleep(relaySyncDelay) // Build the inner shell script that runs inside bwrap. // Steps: - // 1. Start the Python relay in the background (as root, before iptables). - // 2. Wait for the relay's ready-file (up to 5 s). - // 3. Apply iptables / ip6tables rules. - // 4. Execute the command as user "nobody" with proxy env vars. + // 1. Apply iptables / ip6tables rules. + // 2. Execute the command as user "nobody" with proxy env vars. inner := fmt.Sprintf(`set -e -# Start the TCP→Unix relay in background; redirect its output to /dev/null so -# it does not hold our stdout/stderr pipes open after bwrap tears down. -python3 %s %s %d %s >/dev/null 2>&1 & -RELAY_PID=$! -READY=0 -for i in $(seq 1 100); do - if [ -f %s ]; then READY=1; break; fi - sleep 0.05 -done -if [ "$READY" -eq 0 ]; then - echo "agentjail: relay did not start" >&2 - kill -9 $RELAY_PID 2>/dev/null || true - exit 1 -fi - # IPv4: allow only TCP to/from the proxy port on loopback; drop everything else. iptables -F 2>/dev/null || true iptables -P OUTPUT DROP 2>/dev/null || true @@ -80,12 +76,7 @@ NO_PROXY='' \ no_proxy='' \ HOME=/tmp \ sudo -u nobody %s -EXIT=$? -kill -9 $RELAY_PID 2>/dev/null || true -exit $EXIT `, - relayPath, sockPath, proxyPort, readyPath, - readyPath, proxyPort, proxyPort, proxyPort, proxyPort, proxyPort, proxyPort, buildCmdLine(command), @@ -152,3 +143,4 @@ func buildCmdLine(args []string) string { func shellescape(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } + From 53d0cda46373612ef4701db8c704ac6d3f6557a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 23:29:11 +0000 Subject: [PATCH 5/6] Use exec.CommandContext in startRelay to respect context cancellation Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Agent-Logs-Url: https://github.com/kitproj/agentjail/sessions/bb70831b-1563-4980-a8e1-03f62b06048d --- pkg/agentjail/relay.go | 5 +++-- pkg/agentjail/sandbox.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/agentjail/relay.go b/pkg/agentjail/relay.go index 17e0985..05e2a91 100644 --- a/pkg/agentjail/relay.go +++ b/pkg/agentjail/relay.go @@ -1,6 +1,7 @@ package agentjail import ( + "context" "fmt" "os/exec" ) @@ -14,7 +15,7 @@ import ( // // The caller is responsible for terminating the returned *exec.Cmd when the // relay is no longer needed. -func startRelay(sockPath string, port int) (*exec.Cmd, error) { +func startRelay(ctx context.Context, sockPath string, port int) (*exec.Cmd, error) { socat, err := exec.LookPath("socat") if err != nil { return nil, fmt.Errorf("socat not found (see README for installation instructions): %w", err) @@ -23,7 +24,7 @@ func startRelay(sockPath string, port int) (*exec.Cmd, error) { // TCP-LISTEN: binds 127.0.0.1:port immediately before the first accept, // so the port is ready as soon as socat starts (no separate readiness poll // needed; a brief sleep is sufficient for the socket to be bound). - cmd := exec.Command(socat, + cmd := exec.CommandContext(ctx, socat, fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr,bind=127.0.0.1", port), fmt.Sprintf("UNIX-CONNECT:%s", sockPath), ) diff --git a/pkg/agentjail/sandbox.go b/pkg/agentjail/sandbox.go index 68b8946..fa9d663 100644 --- a/pkg/agentjail/sandbox.go +++ b/pkg/agentjail/sandbox.go @@ -28,7 +28,7 @@ func runInSandbox(ctx context.Context, sockPath string, command []string) error // Start the socat relay on the host before entering the sandbox. // socat binds the TCP port synchronously before handling the first // connection, so relaySyncDelay is enough to ensure it is ready. - relay, err := startRelay(sockPath, proxyPort) + relay, err := startRelay(ctx, sockPath, proxyPort) if err != nil { return err } From 531c20777d2686c0e3bd5fb8687786b5db76987b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:29:21 +0000 Subject: [PATCH 6/6] Add GitHub Actions CI and release workflows Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Agent-Logs-Url: https://github.com/kitproj/agentjail/sessions/f7c87fc3-b834-4700-a46a-488d628e8977 --- .github/workflows/go.yml | 28 +++++++++++++++++++++ .github/workflows/release.yml | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..09a5b81 --- /dev/null +++ b/.github/workflows/go.yml @@ -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 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4815004 --- /dev/null +++ b/.github/workflows/release.yml @@ -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