Agent Sandbox
Project description
agent-uplink
Run a coding agent in a Kata Containers microVM on a local k3s cluster with restricted network access. All^ outbound traffic is routed through a mitmproxy pod that enforces an allowlist and can inject credentials from your OS keyring, so secrets never enter the agent pod.
Agent-agnostic: orchestration is generic, each agent is a subclass under agent_uplink/agents/<name>/. Today only claude is implemented.
Linux only (WSL2 works). Tested against k3s.
Architecture
flowchart LR
subgraph host["host"]
keyring[("OS keyring")]
awscreds[("AWS creds<br/>(host env)")]
dockerd["docker build + push<br/>→ localhost:5000"]
end
subgraph registry_ns["agent-uplink-system (long-lived)"]
registry["registry:2<br/>(hostPort 5000)"]
end
subgraph session_ns["agent-uplink-<id> (per-session)"]
subgraph agent_pod["Pod: agent (kata-clh)"]
agent["agent CLI"]
gitrw["git: SSH→HTTPS rewrite<br/>(baked /etc/gitconfig)"]
end
subgraph mitm_pod["Pod: mitm"]
mitm["mitmproxy<br/>+ filter addon<br/>(re-signs AWS SigV4)"]
end
subgraph ssh_pod["Pod: ssh-agent (--ssh-key-dir)"]
sshagent["ssh-agent<br/>(holds private keys)"]
end
rules[("Secret: rules-json")]
certs[("Secret: mitm-certs")]
creds[("Secret: aws-sigv4-creds<br/>akia → real creds")]
end
internet(("Internet"))
aws(("AWS APIs"))
dockerd -->|push| registry
registry -->|pull| agent_pod
keyring -.->|resolved at startup| rules
awscreds -.->|host-side export| creds
rules --> mitm
certs --> mitm
creds --> mitm
agent -->|HTTPS_PROXY=http://mitm:8080| mitm
agent -.->|"git@host: / ssh://git@host"| gitrw
gitrw -.->|cloned over HTTPS| mitm
agent -.->|"SSH_AUTH_SOCK<br/>sign via holder (no keys in agent)"| sshagent
agent -.->|"TCP 22 only, --ssh-cidr<br/>transport bypasses mitm"| internet
mitm -->|allowed + injected| internet
mitm -->|re-signed with real creds<br/>by dummy AKIA| aws
Install
k3s prerequisites
# install k3s with local custom registry support
sudo mkdir -p /etc/rancher/k3s
sudo tee /etc/rancher/k3s/registries.yaml >/dev/null <<'EOF'
mirrors:
"localhost:5000":
endpoint:
- "http://localhost:5000"
configs:
"localhost:5000":
tls:
insecure_skip_verify: true
EOF
curl -sfL https://get.k3s.io | sh -
# install kata containers
export VERSION=$(curl -sSL https://api.github.com/repos/kata-containers/kata-containers/releases/latest | jq .tag_name | tr -d '"')
export CHART="oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy"
helm install kata-deploy "${CHART}" --version "${VERSION}" -n kata-containers --create-namespace \
--set k8sDistribution=k3s \
--set shims.disableAll=true \
--set shims.qemu.enabled=true \
--set shims.clh.enabled=true
# Import the k3s context into ~/.kube/config as local-k8s-admin
sudo sed \
-e '/server:/{n;s/^ name: default$/ name: local-k8s/}' \
-e 's/^ cluster: default$/ cluster: local-k8s/' \
-e 's/^ user: default$/ user: local-k8s-admin/' \
-e 's/^ name: default$/ name: local-k8s-admin/' \
-e 's/^- name: default$/- name: local-k8s-admin/' \
-e 's/^current-context: default$/current-context: local-k8s-admin/' \
/etc/rancher/k3s/k3s.yaml > /tmp/local-k8s.yaml
KUBECONFIG="$HOME/.kube/config:/tmp/local-k8s.yaml" kubectl config view --flatten > /tmp/kubeconfig.merged
install -m 600 /tmp/kubeconfig.merged "$HOME/.kube/config"
rm /tmp/local-k8s.yaml /tmp/kubeconfig.merged
agent-uplink install
pip install agent-uplink
Requires kubectl, docker, and Python 3.10+. aws CLI is needed for --aws-profiles.
Run from inside your home directory.
agent-uplink deploys the session into the kubeconfig context named by --deploy-context (default local-k8s-admin; pass ''
to use the current-context). This is the cluster it deploys into, distinct from --kube-context, which exposes clusters to
the agent.
Usage
agent-uplink claude --anthropic # Anthropic API
agent-uplink claude --bedrock # AWS Bedrock (bearer token)
agent-uplink claude --anthropic --rules examples/rules/atlassian.yaml
agent-uplink claude --bedrock --aws-profiles profile1 profile2
agent-uplink claude --anthropic --force-rebuild
agent-uplink claude --anthropic --rules examples/rules/ecr.yaml # authenticated docker pulls (ECR)
agent-uplink claude --anthropic --ssh-cidr 10.0.0.0/24 --ssh-key-dir ~/keys/agent # SSH egress
agent-uplink claude --anthropic --rules examples/rules/git.yaml # git over HTTPS
agent-uplink claude --anthropic --git-https-rewrite git.example.com # rewrite an extra host
agent-uplink claude --anthropic --kube-context dev-cluster # k8s cluster access
agent-uplink claude --anthropic --kube-context ctx-a ctx-b --kubeconfig ~/.kube/extra.yaml
agent-uplink claude --anthropic --deploy-context my-cluster # cluster to deploy into
agent-uplink claude --anthropic --mount-rw ~/code/repo-b ~/code/repo-c # mount extra repos (read-write)
agent-uplink claude --anthropic --mount-ro ~/.ansible.cfg # mount a host file read-only
agent-uplink claude --anthropic --maven # opt-in: mount ~/.m2 + Maven proxy env
agent-uplink claude --anthropic -- --resume <session-id> # extra args forwarded to `claude`
agent-uplink claude --anthropic -- -p "summarise the build failure"
--anthropic reads ~/.claude/.credentials.json (run claude login first). --bedrock reads keyring get bedrock key
(set it once with keyring set bedrock key).
Anything after a -- separator is forwarded verbatim to the in-pod claude CLI (e.g. --resume <id>, -c, -p "<prompt>"). The pod defaults to permission mode auto; --allow-dangerously-skip-permissions is always added so
bypassPermissions stays reachable via Shift+Tab (and on models without auto support, e.g. older Sonnet, where
the default falls back to default mode). Pass --permission-mode after -- to override the default.
Each run creates a session namespace agent-uplink-<id>, torn down on exit.
Configuration file
Every CLI flag can be set in a .agent-uplink.yaml file so you don't have to retype them. On an agent run, agent-uplink
reads every .agent-uplink.yaml from the working directory up to and including ~/.agent-uplink.yaml, then applies the
CLI args on top. Precedence, lowest to highest:
~/.agent-uplink.yaml < ... < ./<project>/.agent-uplink.yaml < CLI args
So a home-level file holds your defaults, a project-level file overrides them, and an explicit CLI flag always wins. The
list / clean subcommands ignore config.
Keys are the flag's long name with or without dashes (mount-rw and mount_rw both work). Scalars and booleans follow
the precedence above (closer file wins, CLI wins over all). Repeatable flags are additive — values from every config
file and the CLI accumulate rather than replace:
# ~/.agent-uplink.yaml — personal defaults for every project
anthropic: true # auth mode; or `bedrock: true`, or `auth_mode: anthropic`
debug: false
aws_profiles: [shared-readonly] # additive across files + CLI
mount_ro: [~/.ansible.cfg]
# ./.agent-uplink.yaml — project overrides, layered on top of the home file
rules:
- examples/rules/git.yaml # a rules file (path), additive across files + CLI
- name: jira # an inline rule, same schema as a rules file entry
host: 'mycorp\.atlassian\.net'
inject:
headers:
Authorization: 'Basic {{keyring:jira:me}}'
git_https_rewrite: [git.internal.example.com]
aws_profiles: [project-deploy] # appended -> [shared-readonly, project-deploy]
kube_context: [dev-cluster]
rules is repeatable: list items are either file paths or inline rule mappings, concatenated in order (earlier entries
win first-match), so you can define rules inline without a separate file. --rules a.yaml b.yaml on the CLI behaves the
same way and appends after any config files.
With those two files, agent-uplink claude behaves as if you had passed every flag; agent-uplink claude --bedrock -a extra
switches the mode and appends extra to the AWS profiles. A worked example lives in
examples/agent-uplink.yaml. Unknown keys or invalid values abort startup before any pod is
launched. (The passthrough args after -- are the one exception to "additive": a CLI -- ... replaces a config
claude_args: rather than appending.)
Managing sessions
Teardown rides on the run's signal handlers, so a kill -9, a host crash, or a closed laptop lid can leave a session namespace
(and its pods/microVM) behind. Two subcommands manage the leftovers — both operate on --deploy-context like a run does:
agent-uplink list # show session namespaces with status and age
agent-uplink clean <id> [<id> ...] # delete specific sessions (id or full namespace)
agent-uplink clean --older-than 2h # delete sessions older than a duration (s/m/h/d)
agent-uplink clean --all # delete every session namespace
agent-uplink clean --all --yes # skip the confirmation prompt (for scripts)
clean lists what it will delete and prompts before acting (override with -y/--yes); pass --wait to block until each
namespace is gone. The long-lived registry namespace (agent-uplink-system) is never a target. A bare clean with no
selector is refused, so it can't wipe everything by accident.
Authenticated docker pulls
~/.docker/config.json is never mounted into the pod. Private registry auth is handled the same way as everything else — a mitm
rule injects the Authorization header on the registry host. The in-pod dockerd pulls anonymously; mitm adds the credential.
examples/rules/ecr.yaml shows this for AWS ECR (Basic auth, token resolved on the host via {{exec:...}}, never entering the
pod).
SSH egress
By default the agent pod reaches only mitm and kube-dns, so SSH is blocked. The SSH transport still bypasses mitm —
SSH is not HTTP, so there is no allow-list or rule engine for it; reachability is the only control. Two flags open it:
--ssh-cidr <CIDR> [<CIDR> ...]— allows TCP 22 only to those CIDRs (a bare IP becomes/32). This is the sole control on which hosts SSH can reach, so scope it tightly. NetworkPolicy matches resolved IPs, not DNS names, so mind DNS/CDN churn for hosts like GitHub.--ssh-key-dir <DIR>— the private keys never enter the agent pod. They are loaded into anssh-agentin a separate, hardened holder pod, and the agent reaches it over a socat bridge, so it can sign but never read the key bytes. (The holder is a separate pod because the privileged agent container could read a same-pod sidecar's memory.) The agent gets only the public keys + anyconfig, placed in the standard~/.ssh; signing happens in the holder. Pin a key to a host withIdentityFile ~/.ssh/<name>.pub+IdentitiesOnly yes. Keys must be passphraseless (the holder loads them non-interactively).
This protects key confidentiality — a compromised agent can't steal the keys — but not authorization: the agent can still
use a key against any host the --ssh-cidr set allows, so that CIDR set remains the egress control. The flags are independent
but want each other (each logs a warning if used alone).
Git over HTTPS
SSH egress is for shelling into machines, not git. Git runs over HTTPS, through mitm, so the allow-list governs it
and injects credentials host-side. The agent image bakes insteadOf rewrites for github.com, gitlab.com,
bitbucket.org that turn SSH remotes (git@host:owner/repo, ssh://git@host/...) into HTTPS at operation time, so
existing SSH remotes and submodules just work — git clone git@github.com:owner/repo.git becomes an HTTPS clone.
--git-https-rewrite <HOST> [<HOST> ...]— rewrite extra hosts (e.g. self-hosted GitLab) too.--no-git-identity— by default the host'suser.name/user.emailare surfaced so commits are attributed; this omits them. The injected config carries no secrets and leaves the agent's~/.gitconfigwritable.
Auth is opt-in: the default allow-list permits only GET/OPTIONS/HEAD, but git fetch/push POST to
git-upload-pack/git-receive-pack. Pass --rules examples/rules/git.yaml to allow those endpoints and inject HTTP
Basic auth (token resolved on the host, never entering the pod). Without it, even a public clone is denied.
Extra mounts
By default the agent sees only the current working directory, mounted read-write at its host path. Two flags add more host files or directories, each at its identical path — useful for cross-repo work or sharing a host config:
agent-uplink claude --anthropic --mount-rw ~/code/repo-b ~/code/repo-c # extra repos, read-write
agent-uplink claude --anthropic --mount-ro ~/.ansible.cfg # a host config, read-only
Constraints (startup is refused if any are violated):
- Each path must exist and be under
/home/<user>/, the same rule the working directory follows. - The same path can't be both
--mount-rwand--mount-ro. - A writable directory may not be an ancestor or descendant of the working directory or another writable directory at
any depth. Siblings are fine; read-only mounts and files may sit anywhere (e.g. a read-only file inside a read-write
repo). The
File/DirectoryhostPath type is auto-detected.
The session still opens in the working directory (cd "$WORKDIR"); the extra paths are simply available alongside it.
There is no auto-mounting by file existence — every host integration (e.g. Maven via --maven) is explicit.
Kubernetes cluster access
--kube-context <ctx> [<ctx> ...] exposes one or more host kubeconfig contexts to the agent. Unlike SSH egress, k8s traffic
flows through mitm and is fully governed by the allow-list — no NetworkPolicy is modified.
For each context, agent-uplink reads the cluster CA, server URL, and credentials from the host kubeconfig (kubectl config view --flatten --minify), then:
- Produces a sanitized pod kubeconfig: real server URL, mitm CA for trust, real credentials stripped.
- Wires mitm to inject credentials on the upstream leg — bearer token as an
Authorizationheader, or client certificate presented during TLS. - Adds each cluster's serving CA to mitm's upstream trust store.
Real tokens and client keys never appear in the pod kubeconfig or the agent container.
Supported auth methods: static bearer token (user.token / user.tokenFile) and client certificate
(user.client-certificate-data + user.client-key-data). exec/auth-provider contexts (EKS, GKE, AKS, OIDC) and
insecure-skip-tls-verify are refused at startup with a clear error.
--kubeconfig <path> overrides the source file (default: $KUBECONFIG then ~/.kube/config).
Rules
YAML allow-list, first match wins. Match priority is by layer, not regex length: the agent's auth rule (and any kube
rules) first, then your rules, then agent defaults, then the generic GET/OPTIONS/HEAD-anywhere catch-all last. Auth
and kube rules lead so a broad rule of yours on an overlapping host (e.g. .*\.amazonaws\.com) can't shadow an injected
credential. --no-default-rules (or replace_defaults: true in any rules file) keeps only your rules (and drops the auth
rule).
--rules is repeatable (--rules a.yaml b.yaml); files are concatenated in order, an earlier file winning first-match
over a later one. Rules can also be defined inline in .agent-uplink.yaml under the same rules: key (file paths and
inline rule mappings can be mixed in one list) — see Configuration file.
rules:
- name: my-rule
host: '<regex>' # required
methods: [GET, POST] # optional
paths: ['<regex>'] # optional
inject: # optional
headers:
Authorization: 'Bearer {{keyring:my-service:my-user}}'
Header values support two placeholder forms, both resolved on the host before the mitm pod starts:
{{keyring:SERVICE:USERNAME}}— static secret from the OS keyring (keyring set my-service my-user).{{exec:COMMAND}}— stdout (trailing newline stripped) of a host shell command, for short-lived dynamic credentials the keyring can't hold (e.g. an AWS CodeArtifact auth token). Off unless you pass--allow-exec.
See examples/rules/.
Security
This is a fun side project that was nearly all written with claude, no guarantees about security are made. It's a local, single-user tool, and is not a malware sandbox. Known limitations/tradeoffs of the egress control:
- Default rules allow
GET/OPTIONS/HEADto any host, so with defaults on, anything the agent can read can be exfiltrated via GET query strings/headers. For untrusted workloads, run--no-default-ruleswith an explicit allow-list. - DNS to kube-dns is allowed (
^) — a residual exfiltration channel the mitm allow-list never sees. --allow-execlets a--rulesfile run host shell commands at startup - only enable it for rules files you trust.--ssh-cidropens TCP 22 to the given CIDRs bypassing mitm entirely (no allow-list or rule engine for the SSH transport) — scope the CIDRs tightly. With--ssh-key-dirthe private keys stay in a separate holder pod and never enter the agent (it signs via an ssh-agent bridge), so the keys can't be stolen, but they can still be used against any host the CIDRs allow.- For the
claudeagent, the host~/.claude/settings.jsonis currently copied into the pod wholesale (only the top-levelsandboxkey is dropped andpermissionsis replaced). Secret-bearing keys —apiKeyHelperand any secretenvvars — therefore do reach the agent pod'ssettings.json, so keep secrets out of your hostsettings.json.
^ NetworkPolicies can't restrict traffic for pod <-> host where the pod is scheduled.
Testing
pip install -e ".[tests]"
pytest tests/unit # fast, no cluster
pytest tests/integration # live k3s; deploys namespaces/pods/policies
pytest tests # everything
Unit tests need nothing. The integration suite runs against a live k3s cluster
(reusing the same kubectl + docker + localhost:5000 registry setup the tool
itself needs) and focuses on the security posture: credentials never reaching the
agent pod, the agent's egress being confined to mitm + kube-dns, and the
allow-list / credential-injection / SigV4 re-signing behaving as designed. No real
credentials are needed — every secret is a dummy or a sentinel. Pods run
privileged on the default runtime (no kata), so the suite runs on a bare k3s /
GitHub runner; .github/workflows/integration-tests.yml does exactly that. If no
cluster is reachable the integration suite skips itself. See
tests/README.md for the design and the findings it surfaced.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_uplink-0.11.1.tar.gz.
File metadata
- Download URL: agent_uplink-0.11.1.tar.gz
- Upload date:
- Size: 73.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d23170764dc5ebeaab3ec34cd08d26d256621a62001c691d485fe48e4e49568a
|
|
| MD5 |
cab5d43435fa805aaab2d0fd8e3f8808
|
|
| BLAKE2b-256 |
c5c1f55e292fbfd432b2477e8f990e5d2e47986d6669ea7317676f0329c67a74
|
Provenance
The following attestation bundles were made for agent_uplink-0.11.1.tar.gz:
Publisher:
publish_module.yml on hoo29/agent-uplink
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_uplink-0.11.1.tar.gz -
Subject digest:
d23170764dc5ebeaab3ec34cd08d26d256621a62001c691d485fe48e4e49568a - Sigstore transparency entry: 1935558799
- Sigstore integration time:
-
Permalink:
hoo29/agent-uplink@7cc164eabdce492dd23c9a8fe0485dc560f13438 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hoo29
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish_module.yml@7cc164eabdce492dd23c9a8fe0485dc560f13438 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_uplink-0.11.1-py3-none-any.whl.
File metadata
- Download URL: agent_uplink-0.11.1-py3-none-any.whl
- Upload date:
- Size: 76.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
644569be74af5d1580284e0ecbac0e40104aa4524062c0edecc4943efca57551
|
|
| MD5 |
7c1786e76f959260d5b971330e55e7f7
|
|
| BLAKE2b-256 |
7d97255b9e1acf5cded5d48b6e7653aa96f296940a69fb38dec7520d6f635d3f
|
Provenance
The following attestation bundles were made for agent_uplink-0.11.1-py3-none-any.whl:
Publisher:
publish_module.yml on hoo29/agent-uplink
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_uplink-0.11.1-py3-none-any.whl -
Subject digest:
644569be74af5d1580284e0ecbac0e40104aa4524062c0edecc4943efca57551 - Sigstore transparency entry: 1935558840
- Sigstore integration time:
-
Permalink:
hoo29/agent-uplink@7cc164eabdce492dd23c9a8fe0485dc560f13438 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hoo29
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish_module.yml@7cc164eabdce492dd23c9a8fe0485dc560f13438 -
Trigger Event:
push
-
Statement type: