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

@archfarchf commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

DUNDER-43

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: legacyqueue: coding-agentreserve_for: 300retries: 0args:
- image: "nicolaka/netshoot:latest"commands:
- "sleep 41"variables:
- key: "PROXY_ALLOWED_DOMAINS"value: "httpbin.org,www.amazon.ca"sensitive: falsefiles: []

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
archfforce-pushed the feature/add-coding-agent-sidecar-proxy branch from 8b7f04e to 7d5bc95CompareJuly 3, 2026 22:01
Comment threadsrc/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
ContributorAuthor

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-opslevelderek-etherton-opslevelJul 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
ContributorAuthor

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 threadsrc/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

@archfarchfJul 10, 2026

Copy link
Copy Markdown
ContributorAuthor

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-opslevelderek-etherton-opslevelJul 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
ContributorAuthor

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 freeto 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

@archf@derek-etherton-opslevel