Skip to main content

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-&lt;id&gt; (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 an ssh-agent in 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 + any config, placed in the standard ~/.ssh; signing happens in the holder. Pin a key to a host with IdentityFile ~/.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's user.name/user.email are surfaced so commits are attributed; this omits them. The injected config carries no secrets and leaves the agent's ~/.gitconfig writable.

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-rw and --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/Directory hostPath 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 Authorization header, 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/HEAD to 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-rules with an explicit allow-list.
  • DNS to kube-dns is allowed (^) — a residual exfiltration channel the mitm allow-list never sees.
  • --allow-exec lets a --rules file run host shell commands at startup - only enable it for rules files you trust.
  • --ssh-cidr opens 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-dir the 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 claude agent, the host ~/.claude/settings.json is currently copied into the pod wholesale (only the top-level sandbox key is dropped and permissions is replaced). Secret-bearing keys — apiKeyHelper and any secret env vars — therefore do reach the agent pod's settings.json, so keep secrets out of your host settings.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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_uplink-0.11.0.tar.gz (73.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_uplink-0.11.0-py3-none-any.whl (76.4 kB view details)

Uploaded Python 3

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page