Skip to content

feat: add coding-agent egress proxy sidecar support#306

Open
archf wants to merge 5 commits into
mainfrom
feature/add-coding-agent-sidecar-proxy
Open

feat: add coding-agent egress proxy sidecar support#306
archf wants to merge 5 commits into
mainfrom
feature/add-coding-agent-sidecar-proxy

Conversation

@archf

@archf archf commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Description

Add an egress proxy sidecar when job is read from a specific queue (i.e.: coding-agent).

Queue must be passed in the job spec. We're not relying on the --job-agent-mode since this would cause the pod to run in privileged mode, what we do not want. We can always leverage `--job-agent-mode later on once we retire SWE-Agent.

Tophatting

Tested by enqueuing a job in Faktory mode

type: legacy
queue: coding-agent
reserve_for: 300
retries: 0
args:
  - image: "nicolaka/netshoot:latest"
    commands:
      - "sleep 41"
    variables:
      - key: "PROXY_ALLOWED_DOMAINS"
        value: "httpbin.org,www.amazon.ca"
        sensitive: false
    files: []

Opslevel-Runner logs

[...]
17:32:46  coding-agent | 5:32PM INF job started job_id=coding-agent-proxy-test-1783114365 runner=faktory
17:32:46  coding-agent | 5:32PM DBG job input received commands=["sleep 41"] files=0 image=nicolaka/netshoot:latest job_id=coding-agent-proxy-test-1783114365 namespace=default runner=faktory variables=2
17:32:46  coding-agent | 5:32PM TRC Starting log streamer ... runner=faktory
17:32:46  coding-agent | 5:32PM DBG creating resource data_keys=[] immutable=true job_id=coding-agent-proxy-test-1783114365 kind=ConfigMap name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:46  coding-agent | 5:32PM DBG created resource job_id=coding-agent-proxy-test-1783114365 kind=ConfigMap name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:46  coding-agent | 5:32PM DBG creating resource job_id=coding-agent-proxy-test-1783114365 kind=PodDisruptionBudget max_unavailable=0 name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory selector=app.kubernetes.io/instance=opslevel-job-coding-agent-proxy-test-1783114365-1783114366,app.kubernetes.io/managed-by=runner-faktory
17:32:46  coding-agent | 5:32PM DBG created resource job_id=coding-agent-proxy-test-1783114365 kind=PodDisruptionBudget name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:46  coding-agent | 5:32PM DBG creating resource containers=1 cpu_limit=1 cpu_request=50m env_count=5 image=nicolaka/netshoot:latest init_container_names=["helper","squid"] init_containers=2 job_id=coding-agent-proxy-test-1783114365 kind=Pod mem_limit=1Gi mem_request=32Mi name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default privileged=true restart_policy=Never runner=faktory volume_count=4
17:32:46  coding-agent | 5:32PM DBG created resource job_id=coding-agent-proxy-test-1783114365 kind=Pod name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:46  coding-agent | 5:32PM DBG waiting for pod job_id=coding-agent-proxy-test-1783114365 kind=Pod name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory timeout_seconds=900
17:32:51  coding-agent | 5:32PM DBG pod ready duration_ms=5005 job_id=coding-agent-proxy-test-1783114365 kind=Pod name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:51  coding-agent | 5:32PM DBG execing pod container=job job_id=coding-agent-proxy-test-1783114365 kind=Pod name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:32:51  coding-agent | 5:32PM TRC exec request job_id=coding-agent-proxy-test-1783114365 namespace=default runner=faktory url=https://127.0.0.1:51687/api/v1/namespaces/default/pods/opslevel-job-coding-agent-proxy-test-1783114365-1783114366/exec?command=%2Fbin%2Fsh&command=-e&command=-c&command=mkdir+-p+%2Fjobs%2Fcoding-agent-proxy-test-1783114365%3B%0Acd+%2Fjobs%2Fcoding-agent-proxy-test-1783114365%3B%0Aset+-xv%3B%0Asleep+41&container=job&container=job&stderr=true&stdout=true&timeout=1m0s
17:32:51  coding-agent | 5:32PM TRC Shipping logs because its the first line ... runner=faktory
17:33:32  coding-agent | 5:33PM DBG pod exec complete duration_ms=41057 job_id=coding-agent-proxy-test-1783114365 kind=Pod name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:33:32  coding-agent | 5:33PM DBG deleting resource job_id=coding-agent-proxy-test-1783114365 kind=PodDisruptionBudget name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:33:32  coding-agent | 5:33PM DBG deleted resource job_id=coding-agent-proxy-test-1783114365 kind=PodDisruptionBudget name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:33:32  coding-agent | 5:33PM DBG deleting resource job_id=coding-agent-proxy-test-1783114365 kind=ConfigMap name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:33:32  coding-agent | 5:33PM DBG deleted resource job_id=coding-agent-proxy-test-1783114365 kind=ConfigMap name=opslevel-job-coding-agent-proxy-test-1783114365-1783114366 namespace=default runner=faktory
17:33:32  coding-agent | 5:33PM TRC Starting log streamer flush ... runner=faktory
17:33:32  coding-agent | 5:33PM TRC Finished log streamer flush ... runner=faktory
17:33:32  coding-agent | 5:33PM TRC Shutting down log streamer ... runner=faktory
17:33:32  coding-agent | 5:33PM TRC Flushing log processors ... runner=faktory

Test

> tests/enqueue-coding-agent-job.sh
Applying squid-config ConfigMap...
configmap/squid-config unchanged
Deleting dangling coding-agent pods...
pod "opslevel-job-coding-agent-proxy-test-1783115806-1783115807" deleted from default namespace
Enqueuing coding-agent proxy test job (ID: coding-agent-proxy-test-1783115895) ...
5:58PM DBG Submitting Faktory job payload={"args":[{"commands":["sleep 41"],"files":[],"image":"nicolaka/netshoot:latest","variables":[{"key":"PROXY_ALLOWED_DOMAINS","sensitive":false,"value":"httpbin.org,www.amazon.ca"}]}],"created_at":"2026-07-03T21:58:15.663381Z","custom":{"opslevel-runner-job-id":"coding-agent-proxy-test-1783115895"},"jid":"wIxStxn9QZrDBBab","jobtype":"legacy","queue":"coding-agent","retry":25}

Job enqueued (ID: coding-agent-proxy-test-1783115895) on queue 'coding-agent'

Pod created: opslevel-job-coding-agent-proxy-test-1783115895-1783115895
Waiting for the coding-agent job pod to become Ready...
pod/opslevel-job-coding-agent-proxy-test-1783115895-1783115895 condition met

Testing egress proxy inside pod opslevel-job-coding-agent-proxy-test-1783115895-1783115895 ...

===================================================================
PROBE: httpbin.org  url=https://httpbin.org/get  expected=allow
===================================================================
HTTP 200
RESULT: PASS  httpbin.org -> allow (expected allow)

===================================================================
PROBE: www.amazon.ca  url=https://www.amazon.ca/  expected=allow
===================================================================
HTTP 200
RESULT: PASS  www.amazon.ca -> allow (expected allow)

===================================================================
PROBE: github.com  url=https://github.com/  expected=allow
===================================================================
HTTP 200
RESULT: PASS  github.com -> allow (expected allow)

===================================================================
PROBE: bitbucket.org  url=https://bitbucket.org/  expected=allow
===================================================================
HTTP 200
RESULT: PASS  bitbucket.org -> allow (expected allow)

===================================================================
PROBE: xkcd.com  url=https://xkcd.com/2347/  expected=deny
===================================================================
curl: (7) CONNECT tunnel failed, response 403
HTTP 000
RESULT: PASS  xkcd.com -> deny (expected deny)


Probes complete.

Pod remains alive for interactive follow-up:
  kubectl exec -it -n default -c job opslevel-job-coding-agent-proxy-test-1783115895-1783115895 -- bash

Squid access log (proxy-level ALLOW/DENY audit):
  kubectl logs -n default -c squid opslevel-job-coding-agent-proxy-test-1783115895-1783115895

Faktory: http://localhost:7420

@archf archf force-pushed the feature/add-coding-agent-sidecar-proxy branch from 8b7f04e to 7d5bc95 Compare July 3, 2026 22:01
Comment thread src/pkg/k8s.go
corev1.EnvVar{Name: "http_proxy", Value: proxyURL},
corev1.EnvVar{Name: "https_proxy", Value: proxyURL},
corev1.EnvVar{Name: "no_proxy", Value: "localhost,127.0.0.1,::1"},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good for reducing accidental exposure, but I don't think it's as hard of a restriction as we need. A determined agent could work around this by unsetting the env vars, or using some connection method that doesn't respect the env vars like opening a raw TCP socket e.g. here's a simple bypass that can "fail" in the existing test script:

  env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \
    curl -sS --max-time 15 -o /dev/null -w "%{http_code}\n" https://xkcd.com/2347/

I think the original proposal mentioned having an init container with NET_ADMIN that can set iptables -- that shape sounds like it comes with more guarantees, since our main container won't have NET_ADMIN privileges to get around it. That said, as long as we have some path to squid proxy being a hard limiter, then I'd be in favour of shipping this as-is and plopping the "more hardened" fix on top. This addition of squid proxy is the bulk of the work and looks good, I just want to be sure a little more iteration can lead to something impenetrable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK so I did a bit more reading and I think iptables still leaves a small gap allowing DNS tunneling: If a user owns domain evil.com and asks the agent to hit <sensitive-data>.evil.com, the agent would still be able to send a UDP packet to resolve the IP at <sensitive-data>.evil.com, which allows the attacker to successfully record <sensitive-data>.

Getting around this would require we also implement some sort of DNS filtering. I'm not sure if it's worth the effort at this point, but definitely something worth documenting as a gap, and maybe we could go the route of abuse detection instead of implementing yet another sidecar (or mucking with our cluster-wide DNS service which sounds scary). This vector is distinctly malicious, requiring deliberately "evil" + clever prompting, whereas the other stuff we're fixing has the possibility of being a bit more incidental...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't thought of it that way. If the LLM can figure there's an environment variable to unset, it would indeed be able to escape.

Perhaps we need to engineer the networking differently: when the LLM unsets the env vars, no networking at all should work.

Job pods may only reach:

  • DNS
  • A shared squid proxy service deployment

And All other direct connection attempts are blocked with a k8s NetworkPolicy

For the customer-specific escape hatch, we would need to augment the shared squid proxy with an ACL helper with this directive in squid v7 and bake the account ID, perhaps, in the http_proxy env var.

This directive is not available in the v8 version of Squid.

But unfortunately there is this warning in the documentation and it might not be so future forward. We could need to re-implement the whole thing or get stuck at v7 forever.

But then the problem of the LLM forging random accountID and looping over http_proxy en vars comes in, but it might be good enough given the sheer amount of effort it would need to go through to figure a) a working/valid account ID that b) grants access to a extra very specific domains it doesn't know about in the first place.

Alternatively, we can dig into a solution with Envoy Proxy.

sequenceDiagram
    autonumber

    participant User as User / Web UI
    participant App as OpsLevel Application
    participant DB as RDS MySQL
    participant Runner as opslevel-runner
    participant K8s as Kubernetes API
    participant Pod as Job Pod
    participant Envoy as Envoy Egress Proxy
    participant AuthZ as Egress AuthZ Service
    participant Internet as Destination

    User->>App: Configure additional allowed domains
    App->>DB: Persist account egress policy

    Note over Runner,DB: Later, when scheduling a job

    Runner->>Runner: Obtain accountID and jobID
    Runner->>Runner: Build Pod spec with<br/>HTTP_PROXY=http://accountID:jobID@envoy-egress:3128
    Runner->>K8s: Create Job Pod
    K8s->>Pod: Start Pod

    Note over Pod,Envoy: NetworkPolicy allows only DNS and Envoy

    Pod->>Envoy: CONNECT api.vendor.example:443<br/>Proxy-Authorization contains accountID:jobID

    Envoy->>AuthZ: ext_authz CheckRequest<br/>accountID, jobID, host, port, method

    AuthZ->>DB: Verify job/account and load account policy
    DB-->>AuthZ: Job metadata + allowed domains

    AuthZ->>AuthZ: Evaluate:<br/>global allowlist<br/>+ account allowlist<br/>default deny

    alt Allowed
        AuthZ-->>Envoy: ALLOW
        Envoy->>Internet: Resolve and connect
    else Denied or unknown
        AuthZ-->>Envoy: DENY
        Envoy-->>Pod: 403 / proxy denial
    end
Loading

ext_authz is the adapter between Envoy and the existing application data to retrieve the account specific allowed domains.

Envoy proxy looks to be better fitted for cloud environments at a first glance. The immediate win is that we would not need to have the customer-specific domains circulate from the app to the orchestrator to the job. It will be pulled directly from the datastore where it is needed.

But that extra complexity is required if we do want a unique per customer unique configuration consisting of a specific layer of allowed domains layered on top of the shared one.
It could be acceptable to regenerate a globally shared squid configmap and hot reload squid proxies with the base layer plus all the account specific added domains as I would trust that a given customer would not allow himself access to an evil domain.

@derek-etherton-opslevel derek-etherton-opslevel Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm is there a reason just changing iptables in a privileged (NET_ADMIN) initcontainer wouldn't work for restricting egress to just the squid proxy? I think this is what Paul proposal + AI recommended. The idea is our main LLM container wouldn't have permissions to change it afterwards to get around the restriction, unlike env vars

I'm not sure how much of a pain it is to set this up in the init container tho...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can block an IP with iptables but you can't put up domain based rules that easily. You need to convert them all to IPs first. Were back to fqdn-controller or fqdn-policy.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh yeah that's why I'm suggesting this works with the squid proxy approach -- we keep everything in this PR basically, and then the iptables config is just to restrict our main container's traffic to the sidecar IP. Then squid can still handle the DNS magic, right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here's a quick PoC to sanity check: 5f153b1

Comment thread src/pkg/k8s.go
// so kubelet gates the *next* init container on its TCP startupProbe.
// working with the queue name; can't use agentMode until agentMode stops
// implying privileged mode
if s.podConfig.Queue == "coding-agent" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for "v2" (follow-up), we should just add a new arg that can be specified when starting the runner, e.g. --use-proxy. The coupling to coding-agent queue name is a bit weird in that it points back to our deployed chart's specific implementation detail - not really relevant to the runner code itself

@archf archf Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point. We can add a --proxy flag immediately and inject the sidecar based on that. But I had a mental model where we had a single opslevel-runner instance running that was 'smart'. IIRC we added a second opslevel-runner instance for the privileged containers as we needed to pass a flag.

Interim solution: add a --proxy-queue-name=<job queue name for which to add sidecar>

And then we can potentially go back to a single opslevel-runner deployment

@derek-etherton-opslevel derek-etherton-opslevel Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo the runner's queue handling is very bad - there is no separation of queue + worker, so higher priority queues starve out lower priority queues completely. From that perspective, we'll probably need to keep two runner deployments for now even if they don't need separate flags (e.g. agent-mode). It's useful to just have two workers picking off of different queues.

The proxy potentially being for specific queue within a deployment is a good point though, I hadn't thought about that. --proxy-queue-name does the job I think, even if we don't squash down to one runner

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--proxy-queue-name would allows for any possibilities.

I did not like it much that the queue name was hardcoded tbh. I'm glad that we were finding new possible solutions whatever we decide.

SCRIPT_DIR="${BASH_SOURCE[0]%/*}/../bin"
source "$SCRIPT_DIR/kind-env.sh"

echo "Applying squid-config ConfigMap..."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's one big gap left here: We're requiring the baseline squid configmap be applied before running jobs, otherwise the pod will get stuck trying to mount the squid volume.

In line with opslevel-runner managing all the k8s resources internally, I think this configmap should actually be in the runner code, and it should apply the baseline configmap itself. The consumer shouldn't need to do anything, except specify allowed_domains overrides as desired.

Basically we should be able to remove "step 1" in this test script entirely, and ol-runner should do it all internally I think

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants