Skip to content

OpenShell Backend

The OpenShell backend runs AI agents inside OpenShell sandboxes with network policy enforcement, filesystem isolation, and Landlock-based access control.

New to OpenShell? Start with Local Development with OpenShell, a step-by-step guide to running agents in a sandbox on your workstation.

Requirements

OpenShell 0.1.2 or later, for both the openshell CLI and the openshell-gateway binary (the CI image pins v0.1.6-rhaiv.4). Before it starts a gateway, agentic-ci reads openshell --version and openshell-gateway --version and stops with OpenShell >= 0.1.2 required; <binary> is <version> when either is older (gateway.MIN_OPENSHELL_VERSION). Older runtimes lack gateway.toml schema version 2, sandbox provider attach|detach --wait and the profile fields agentic-ci uses.

How It Works

The backend manages three components: a gateway (control plane), a provider (credentials), and a sandbox (isolated execution environment). On each agentic-ci run --backend openshell, it:

  1. Starts the OpenShell gateway with TLS and mTLS auth
  2. Creates a Vertex AI, Anthropic, or OpenAI credential provider from one of agentic-ci's provider profiles (none for a Claude subscription OAuth token)
  3. Creates a sandbox container from the specified image, with an explicit base policy
  4. Applies a network policy and waits for it to activate
  5. Runs the .agentic-ci/config.yml setup steps on the host, only with the deprecated local opt-in --allow-host-setup (see Setup Steps)
  6. Uploads the workdir (including setup step outputs) into the sandbox
  7. Provisions the sandbox profile's toolchains, runs its setup steps in the sandbox and writes ENVIRONMENT.md, if the profile has them (see Setup order)
  8. Uploads an env script with agent configuration
  9. Executes the agent inside the sandbox
  10. Runs the sandbox profile's validate commands in the sandbox and removes its discard_before_download paths (see Run order)
  11. Downloads the workdir back to the host and restores the host's git control files (see Host git after the run)
  12. Tears everything down on completion

OpenShell Commands

Below is the exact sequence of openshell CLI commands that agentic-ci executes. All management commands go through the openshell client CLI, which talks to the running openshell-gateway server over gRPC.

Gateway Setup

# Check the runtime version (before a gateway is started)
openshell --version
openshell-gateway --version

# Check if gateway is already running
openshell status

# Generate TLS certificates for sandbox JWT auth
openshell-gateway generate-certs \
  --output-dir ~/.local/state/openshell/tls \
  --server-san host.openshell.internal

# Start the gateway server (background process)
# Reads config from ~/.config/openshell/gateway.toml
openshell-gateway --db-url sqlite::memory: --log-level info

# Register the gateway with the CLI
openshell gateway add https://localhost:17670 --local --name ci

# Wait for the gateway to become healthy (retries)
openshell status

The gateway config (gateway.toml) is generated by agentic-ci:

[openshell]
version = 2

[openshell.gateway]
bind_address = "0.0.0.0:17670"
compute_driver = "podman"

# Only added when OPENSHELL_SUPERVISOR_IMAGE and/or
# OPENSHELL_SANDBOX_RUNTIME_IMAGE are set
[openshell.drivers.podman]
supervisor_image = "quay.io/opendatahub/odh-openshell-supervisor:<tag>"
sandbox_runtime_image = "quay.io/opendatahub/odh-openshell-sandbox:<tag>"

Schema version 2 (required since OpenShell v0.0.116-rhaiv.8) takes a singular compute_driver and rejects the version 1 compute_drivers list. agentic-ci leaves host_gateway_ip unset: on v0.1.x the podman driver's trusted host gateway defaults to 127.0.0.1, which lets the sandbox's policy DNS map host.openshell.internal (the OTel collector's address). The gateway reads gateway.toml only at startup, so when setup() finds a running gateway whose file no longer matches the environment (for example after either image variable changed), it deletes the sandbox, stops the gateway and starts a fresh one.

Base Sandbox Policy

openshell sandbox create is always given --policy with a base policy (policy.BASE_POLICY: version: 1, a read-only system path list, /tmp read-write, the workdir included, Landlock best_effort), which matches the restrictive default of the OpenShell release agentic-ci used before. Without it, OpenShell discovers the image's own /etc/openshell/policy.yaml; the Hummingbird agentic images ship one in a different schema, and the supervisor rejects the sandbox with Image policy is invalid rather than falling back. The file never contains a null field: the v0.1.x policy schema refuses null and unknown fields. Network endpoints and binaries are layered on afterwards with openshell policy update, exactly as before.

Provider Profiles

OpenShell ships no provider profiles in the gateway, so agentic-ci imports its own before it creates a provider (provider.ensure_profile()), from src/agentic_ci/backends/openshell/profiles/:

Profile Auth mode Endpoints Binaries
agentic-ci-google-vertex-ai vertex *-aiplatform.googleapis.com, aiplatform.googleapis.com, aiplatform.{us,eu}.rep.googleapis.com (L7 rest, enforce) claude, opencode, codex
agentic-ci-anthropic api-key api.anthropic.com (L4, uninspected credentials allowed) claude, opencode
agentic-ci-openai openai api.openai.com (L4, uninspected credentials allowed) codex

The binaries are the agent launchers under /usr/local/bin and /usr/local/sbin, never curl: OpenShell composes a network rule from the attached provider's profile (_provider_ci_gcp), which the sandbox policy cannot remove, and the proxy injects the credential for any caller that rule admits. The Vertex profile is OpenShell's google-vertex-ai example (unchanged from v0.0.116-rhaiv.11 to v0.1.6-rhaiv.4) with agentic-ci's id and binaries; the example declares no binaries.

A provider kept on a running gateway composes that rule from the gateway's copy of its profile, which an older agentic-ci or a hand edit may have changed. Before it reuses a provider, agentic-ci therefore checks the copy of that auth mode's profile (provider.ensure_auth_mode_profile()); when it had to import or update it, the sandbox is recreated ("Provider profile updated; recreating OpenShell sandbox"). v0.1.2 recomposes a running sandbox's rule as soon as the profile changes (seen live for a curl binary added by hand), so the recreation keeps a sandbox that ran under the drifted rule from being reused rather than being the only fix.

openshell provider profile export agentic-ci-google-vertex-ai -o yaml   # import if missing
openshell provider profile import \
  -f src/agentic_ci/backends/openshell/profiles/agentic-ci-google-vertex-ai.yaml

Provider Setup

The provider injects credentials into the sandbox. The setup differs by auth mode.

Vertex AI with User OAuth (local development)

openshell provider get ci-gcp                    # check if exists
openshell provider create \
  --name ci-gcp \
  --type agentic-ci-google-vertex-ai \
  --from-gcloud-adc \
  --config VERTEX_AI_PROJECT_ID=<PROJECT> \
  --config VERTEX_AI_REGION=global

Requires gcloud auth application-default login to have been run first. The --from-gcloud-adc flag reads the user's OAuth refresh token from ~/.config/gcloud/application_default_credentials.json and mints an initial access token synchronously. The sandbox receives GOOGLE_VERTEX_AI_TOKEN as an opaque placeholder.

Vertex AI with Service Account (CI)

openshell provider get ci-gcp                    # check if exists
openshell provider create \
  --name ci-gcp \
  --type agentic-ci-google-vertex-ai \
  --credential GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN=placeholder \
  --config VERTEX_AI_PROJECT_ID=<PROJECT> \
  --config VERTEX_AI_REGION=global

# Configure JWT-based token refresh from the service account key
openshell provider refresh configure \
  --credential-key GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN \
  --strategy google-service-account-jwt \
  --material client_email=<EMAIL> \
  --material private_key=<PRIVATE_KEY> \
  --secret-material-key private_key \
  ci-gcp

# Mint the initial token immediately (refresh worker runs on 60s interval)
openshell provider refresh rotate \
  --credential-key GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN \
  ci-gcp

The three-step flow is needed because --from-gcloud-adc rejects service account keys. The refresh rotate call triggers immediate token minting instead of waiting for the 60-second background sweep.

A provider an earlier agentic-ci created as google-cloud (or from an imported google-vertex-ai profile) is not reused: setup() deletes it and the sandbox and creates both again. OpenShell removed the GCE metadata emulator the google-cloud provider relied on, but still injects GCE_METADATA_HOST=127.0.0.1:8174 and METADATA_SERVER_DETECTION=assume-present for such a provider, pointing SDKs at a port nothing serves.

How Vertex credentials reach the agent

There is no metadata server in the sandbox, so SDK-side Application Default Credentials cannot mint a token there. Instead the gateway injects the placeholder variable above, and the Claude Code env script sets CLAUDE_CODE_SKIP_VERTEX_AUTH=1, ANTHROPIC_AUTH_TOKEN=<placeholder> and ANTHROPIC_VERTEX_BASE_URL (host plus /v1), so Claude Code sends the placeholder as a bearer token and the supervisor proxy substitutes the real access token on the profile's aiplatform endpoints. Because the profile declares those hosts, policy.AUTH_ENDPOINTS lists none for Vertex: the gateway rejects a sandbox endpoint for the same host with conflicting metadata. The profile's hosts, every other *.aiplatform.googleapis.com host and oauth2.googleapis.com stay agent-only in the egress phases.

OpenCode cannot send the placeholder directly: its @ai-sdk/google-vertex provider always asks google-auth-library for a token first, and that library has no static-token mode. The OpenCode env script therefore writes an external_account ADC file whose token_url points at agentic-ci vertex-token-stub, a loopback HTTP server started in the sandbox that answers every token exchange with the placeholder. The SDK "mints" the placeholder, sends it as the bearer token, and the proxy swaps in the real one. The stub reads the placeholder from AGENTIC_CI_VERTEX_TOKEN and refuses to start when it is empty.

API Key (direct Anthropic API)

openshell provider profile export agentic-ci-anthropic   # import if missing
openshell provider profile import \
  -f src/agentic_ci/backends/openshell/profiles/agentic-ci-anthropic.yaml
openshell provider get ci-gcp                    # check if exists
openshell provider create \
  --name ci-gcp \
  --type agentic-ci-anthropic \
  --credential ANTHROPIC_API_KEY

For Codex, agentic-ci creates an OpenAI provider from its agentic-ci-openai profile and uses OPENAI_API_KEY. The sandbox env script does not carry the key: the provider sets OPENAI_API_KEY in the sandbox to an OpenShell placeholder, Codex's login --with-api-key stores that placeholder, and the proxy swaps it for the real key only on requests to api.openai.com, for any caller the provider's rule admits (a descendant of an agent binary included, such as codex sandbox -- curl), which is why agentic-ci detaches the provider while the setup shim's egress is open. The endpoint is L4, but the proxy still terminates TLS and replaces the placeholder in the headers of every request on the tunnel, including the WebSocket upgrade that carries Codex's Authorization header. The websocket-credential-rewrite and request-body-credential-rewrite endpoint options are not needed, and OpenShell accepts them only on L7 (rest or websocket) endpoints.

OpenShell refuses to inject a key that contains a line break or NUL and answers the request with HTTP 500 (credential_unavailable), which Codex reports as "We're currently experiencing high demand". A key copied into a CI secret often ends with a newline, so agentic-ci strips surrounding whitespace from OPENAI_API_KEY before it stores the key in the provider, the same way codex login --with-api-key does, and stops with an error if a line break remains inside the key. When it strips anything, the run log shows Stripped surrounding whitespace (a line break) from OPENAI_API_KEY (or spaces or tabs), never the key itself.

openshell provider create \
  --name ci-gcp \
  --type agentic-ci-openai \
  --credential OPENAI_API_KEY

agentic-ci ships its own provider profiles (src/agentic_ci/backends/openshell/profiles/) because OpenShell's example openai and anthropic profiles add a rule that lets /usr/bin/curl and /usr/local/bin/curl reach the API host with the key injected, and a sandbox policy cannot remove that rule. agentic-ci's profiles drop that curl rule, and for Codex the raw OpenAI key no longer enters the sandbox. The key can still be spent through an agent binary, for example codex sandbox -- curl, because agentic-ci's own policy lets the agent binaries reach the API host and the proxy injects the key for any of them. The profiles live in the gateway database, so agentic-ci imports them before it creates the provider, and it replaces a provider created from a builtin profile by an earlier release. The sandbox identity file records a short SHA-256 fingerprint of OPENAI_API_KEY (never the key), so after a key rotation agentic-ci recreates the sandbox and stores the new key in the existing provider. Updating the provider alone would not be enough: a running sandbox picks the update up only after about ten seconds, and a placeholder issued before the update keeps resolving to the old key.

OAuth Token (Claude subscription)

When the Claude Code harness runs with CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY, agentic-ci creates no provider. OpenShell's Anthropic provider profiles only carry ANTHROPIC_API_KEY as an x-api-key header, and no profile covers a subscription bearer token. The sandbox is created without --provider, the token is exported by the sandbox env script, and the oauth auth mode is recorded only in the sandbox identity file (~/.config/agentic-ci/openshell-sandbox.json).

API-key exposure

With the Anthropic API key, or the Claude subscription OAuth token, the sandbox env script still exports the real credential, so the agent and its child processes can read it. Only Codex's OpenAI key is kept out of the sandbox so far.

Sandbox Resources

When these are omitted, no --memory, --cpu or --gpu flag is passed and the effective limits come from the compute driver and host configuration rather than from agentic-ci. That limit can sit far below what the host has: a sandbox created without --memory on a 16 GB host was OOM-killed by its memory cgroup at under 4 GiB. Pass memory, cpu and gpu to size the sandbox explicitly:

backend = create_backend(
    "openshell",
    harness=harness,
    memory="8Gi",  # accepts 512Mi, 4Gi, 8G
    cpu="4",  # accepts 500m, 1, 2.5
    gpu=1,  # GPU count
)

All three default to None, which passes no flag and leaves current behavior unchanged. A sandbox profile with resources fills in any of the three the caller did not pass (sandbox_profile= on create_backend, or SkillConfig.sandbox_profile); explicit values win.

Resource limits are fixed when the sandbox is created. setup() reuses an existing sandbox rather than recreating it, so these values do not apply to one that already exists; the backend logs a warning saying so. Delete the sandbox to apply new values.

Two failure modes worth recognizing, because neither says what it is:

  • Exceeding a memory limit does not raise. The cgroup OOM-kills the process, the supervisor log stops mid-line, and the next command reports sandbox is not ready. The kernel is the only witness — journalctl -k on the host shows Memory cgroup out of memory: Killed process.
  • Not requesting a GPU leaves the accelerator invisible to the agent even when the host has one and the container running agentic-ci can see it. nvidia-smi -L inside the sandbox returns nothing.

Sandbox Lifecycle

openshell sandbox get ci                         # check if exists

# Create sandbox with the provider attached (omitted for OAuth token auth).
# --memory / --cpu / --gpu are only passed when the caller sets them.
openshell sandbox create \
  --name ci \
  --no-tty \
  --no-auto-providers \
  --provider ci-gcp \
  --from <SANDBOX_IMAGE> \
  --memory 8Gi \
  --cpu 4 \
  --gpu 1 \
  --policy <base-policy.yaml> \
  --detach \
  -- sleep infinity

# Keep the sandbox's main process alive; agent commands run via exec.

# Apply network policy and wait for the supervisor to compile and load it.
# Built-in defaults are always included. If .agentic-ci/openshell-policy.yml
# exists in the workdir, its endpoints are merged in automatically, unless a
# sandbox profile is set (its agent-phase egress is added instead).
openshell policy update --wait \
  --binary /usr/local/bin/claude \
  --binary /usr/bin/opencode \
  --add-endpoint github.com:443:full \
  --add-endpoint *.github.com:443:full \
  --add-endpoint gitlab.com:443:full \
  --add-endpoint pypi.org:443:read-only \
  --add-endpoint files.pythonhosted.org:443:read-only \
  --add-endpoint api.anthropic.com:443:read-write:::allow-uninspected-credentials \
  ci

# Upload env script with agent configuration
openshell sandbox upload --no-git-ignore ci <env-script-file>
openshell sandbox exec --name ci --no-tty -- \
  bash -c "mv <filename> /tmp/.agentic-ci-env.sh"

# Run the agent
openshell sandbox exec --name ci --no-tty -- \
  bash -c ". /tmp/.agentic-ci-env.sh && exec \"$@\"" -- \
  claude --permission-mode bypassPermissions --model <MODEL> \
  --output-format stream-json --verbose -p "<PROMPT>"

Setup order

OpenShellBackend.setup() runs these steps for a new sandbox:

  1. sandbox.create with the resources and the agent-phase egress (with a sandbox profile, its main process is recorded right after).
  2. Host setup steps from .agentic-ci/config.yml (legacy, host side), only with the deprecated local opt-in --allow-host-setup (refused in CI); otherwise only the step count is logged. See Setup Steps.
  3. sandbox.upload(workdir).
  4. Toolchain provisioning, when the sandbox profile has toolchains: for each one the host resolves the version, downloads and verifies the archive (host cache in ~/.cache/agentic-ci/toolchains) and the sandbox extracts it:

    # Is it already there? Asked only when the host's record of this sandbox
    # (the saved identity) lists it with the same sha256; the marker must agree.
    openshell sandbox exec --name ci --no-tty --no-login-shell -- \
      /usr/bin/python3 -I -S -c <install script> check <plan> /sandbox/.local/toolchains
    # If not: upload the verified archive (named by its sha256) and extract it
    openshell sandbox upload --no-git-ignore ci ~/.cache/agentic-ci/toolchains/<sha256>
    openshell sandbox exec --name ci --no-tty --no-login-shell -- \
      /usr/bin/python3 -I -S -c <install script> install <plan> /sandbox/.local/toolchains <sha256>
    

    Results go to the log and _run/toolchains.json; a toolchain that fails is skipped and the run continues. 5. Setup steps, when the profile has them: switch to the setup egress phase (kill leftovers, detach the provider, setup-phase policy), run each step through the setup shim with its timeout, in the workdir and with an environment agentic-ci builds (toolchain variables and the profile's env, no credential), record them in _run/sandbox-setup.json, then switch to the agent phase (see Setup, validation and records). A failed or timed-out step is recorded and the next one still runs. 6. /sandbox/.agentic-ci/ENVIRONMENT.md and environment.json, for every sandbox profile: toolchains, setup results, validate commands, skips and open presets. 7. Harness config upload, then the sandbox identity (with the profile hash, so a changed profile, toolchains included, recreates the sandbox, the record of the provisioned toolchains and the setup results) is saved.

Without a profile, steps 4 to 6 do nothing; a profile without setup steps switches no phase.

A reused sandbox (same identity) is switched to the agent egress phase and then provisioned again, which skips every toolchain that both the host's record and the marker show as installed with the same sha256 (the marker alone is in a directory the agent can write, so it is never trusted by itself), and saves the updated record. Its setup steps are not run again: the results saved when it was created go to _run/sandbox-setup.json and ENVIRONMENT.md, which is written again. The toolchain variables (PATH prepends, GOTOOLCHAIN=local, the Go and npm caches and the pnpm store) and the profile's env are added to the env script that run() uploads before the agent starts.

Run order

OpenShellBackend.run():

  1. Switches the sandbox to the agent phase first when an earlier switch back to it failed (and raises if that fails again, so no agent starts), moves back what the previous run on this sandbox discarded (see step 3), uploads the env script and starts the agent; waits for it and for the OTel flush.
  2. Validate commands, when the profile has them: switch to the validate phase, which kills every leftover process, then deletes the harness credential files (Codex's auth.json and the others, plus the env script), then detaches the provider and applies the validate-phase policy; run each command through the setup shim; switch back to the agent phase. Results, with the declared skips, go to _run/sandbox-validation.json. None of this changes the exit code. A classifier run skips this step and writes no validation record.
  3. Moves the discard_before_download paths out of the workdir, to /sandbox/.agentic-ci/discarded (_run/sandbox-discard.json). When no validate phase ran (a classifier run, or a profile without validate commands), the leftover processes are killed first, so none can recreate a discarded path before the download.
  4. Downloads the workdir, then writes this run's records (toolchains.json, sandbox-setup.json, sandbox-validation.json, sandbox-discard.json) over whatever the download brought back, removes every one of those names this run did not write, replaces a _run the agent turned into a symlink or file with a directory, and restores the host's git control files.

Teardown

openshell sandbox get ci                         # check if exists
openshell sandbox delete ci
openshell gateway remove ci                      # deregister from CLI
# Gateway and podman service processes are killed by PID

Host git after the run

The workdir download copies the sandbox's .git over the host repository, and the agent can write that .git. Host-side git that runs afterwards (git rm --cached, git commit --amend, git push) would otherwise honor whatever the agent configured: core.hooksPath, core.fsmonitor, core.sshCommand, credential helpers, filter and diff drivers, aliases, include.path, url.*.insteadOf rewrites, hook scripts, and so on, with every secret the host job holds.

run() therefore records the host's git control files before the agent starts and puts them back once the download finishes, even if it fails (snapshot_git_control() and restore_git_control() in agentic_ci/git.py). The restored paths under .git are config, config.worktree, commondir (which redirects git to another config and hooks directory), hooks/, and info/ (which holds attributes). Objects, refs and the index come back from the sandbox unchanged, so the agent's commits are kept. Any hardening applied before the run, such as harden_git_config(), is part of the restored config.

Restoring first renames each path into a quarantine directory inside .git, which git never reads, and then rewrites it from the recorded copy, so a symlink the agent planted is never written through. Nothing the agent wrote is read or walked before that rename, so a deeply nested tree or a huge file cannot make the restore fail while the agent's config is still live. The quarantine is compared with the recorded copy only for the log line below and is then deleted. If the host copy cannot be written back, .git is moved aside and deleted and the run raises GitControlTamperError, so host git finds no repository rather than the agent's config. If the agent replaced .git itself with a file or symlink, the run raises GitControlTamperError and removes that entry. A .git the agent created in a workdir that had none is removed, since host git run from the workdir would otherwise use it even when the workdir sits inside another repository. When the restore runs as root, the recorded owner of each path is put back as well. Changes the agent made are logged as Agent changed git control files in ...; restored host copy of: .... That comparison covers entry names and kinds, file contents, symlink targets and the owner's execute bit, not other permission bits: the upload is extracted under the sandbox user's umask, so every round trip narrows the modes of .git files (for example 0644 to 0640) without the agent touching them. The host's modes are restored either way. Git config the agent sets for its own use does not persist to the host.

Setup Steps

Because the sandbox has no internet access by default, repositories that need dependency installation (e.g. npm ci for Node.js projects) declare it as a sandbox profile's setup steps (in a repo, the sandbox: section of .agentic-ci/config.yml; see Sandbox Overlay), which run inside the sandbox with only the profile's egress presets open and no credential.

The older host setup steps in .agentic-ci/config.yml run on the host before the workdir is uploaded, and only when the caller opts in with --allow-host-setup (or SkillConfig.allow_host_setup=True). The opt-in is deprecated and meant for local use: it is refused when CI, GITLAB_CI or GITHUB_ACTIONS is set, and it logs a deprecation warning. Without it the backend logs Host setup is disabled: N setup step(s) ... not run and runs nothing. With it, each step gets a clean environment (fixed PATH, a temporary HOME, no global or system git config), its process group is killed when it ends, and the workdir's .git control files are restored afterwards. That limits accidental exposure but is not containment: a step can still read any file the user can read, and a process that leaves the step's process group (setsid, setpgid, or shell job control such as set -m) is not killed. See Project Configuration for full details.

Network Policy

Endpoints are applied via openshell policy update --wait after sandbox creation. The --wait flag blocks until the supervisor confirms the policy rules are compiled and active. This prevents a race condition where the agent starts before the policy is ready.

Each endpoint must specify explicit binary paths (--binary /usr/local/bin/claude). Using --binary "*" as a wildcard does not work for CONNECT tunnel requests, which is how HTTPS clients establish connections through the supervisor proxy.

The default endpoints cover:

Endpoint Access Purpose
github.com:443 full GitHub API and git operations
*.github.com:443 full GitHub subdomains (raw, API, etc.)
gitlab.com:443 full GitLab API and git operations
pypi.org:443 read-only Python package index
files.pythonhosted.org:443 read-only Python package downloads
api.anthropic.com:443 read-write Anthropic API (API key or OAuth token auth)
api.openai.com:443 read-write OpenAI API (Codex, API key auth)
chatgpt.com:443 read-write Codex ChatGPT backend API

The Vertex AI hosts are not in this list: the rule OpenShell composes from the agentic-ci-google-vertex-ai profile admits the agent binaries to them, and a sandbox endpoint for the same host with other metadata makes the gateway reject the policy ("network endpoint ambiguity validation failed"). The Anthropic and OpenAI profiles declare their hosts as L4 endpoints that allow uninspected credentials, and the api.anthropic.com and api.openai.com entries above carry the same allow-uninspected-credentials option, so the two rules coexist; OpenShell refuses a CONNECT to a credentialed host when a rule that matches the caller lacks it. OAuth token auth attaches no provider, so its api.anthropic.com rule is plain L4. With the transparent proxy of v0.1.x, a denied connection fails at connect (curl prints Failed to connect, Python raises [Errno 13] Permission denied) instead of getting a 403 answer to a CONNECT, and the sandbox log records it as DENIED <binary> -> <host>:<port> [reason:transparent_tcp_policy_denied].

Project-specific endpoints

Projects can declare additional endpoints in .agentic-ci/openshell-policy.yml. See Project Configuration for details.

With a sandbox profile, the repo policy file is ignored and the profile's egress presets and raw endpoints are added instead. Preset endpoints are L7 (host:443:read-only:rest:enforce): the proxy terminates TLS and allows only GET, HEAD and OPTIONS, and a preset endpoint replaces every other endpoint on port 443 for the same host, wildcards included (such as the default L4 PyPI endpoints).

Egress phases

A sandbox profile's egress has three phases: setup, agent and validate. The rules applied at creation (defaults, auth endpoints, the profile's agent-phase presets and central raw_egress, plus any endpoints from an explicit --policy file, bound to the agent binaries) are the agent phase. OpenShellBackend._set_egress_phase("setup") or ("validate") adds rules bound only to the setup shim, /usr/local/bin/agentic-ci-sandbox-setup, and parks the agent's rules (each agent binary path is prefixed with /proc/agentic-ci-parked, which no process can match), so running an agent binary under the shim gains nothing. ("agent") removes the shim rules and restores the agent's rules. Without a profile it does nothing; a reused profile sandbox is switched to agent in setup().

The switch (sandbox.apply_phase_policy()) reads openshell policy get --base -o json, rewrites only network_policies (drops agentic_ci_phase_* and _provider_* rules, removes the shim from every other rule, parks or restores the agent binaries, adds one shim rule per endpoint) and applies the whole object:

openshell policy get --base -o json ci
openshell policy set --wait --policy <phase-policy.yaml> ci

It never uses openshell policy update, which folds an endpoint whose host overlaps an existing rule into that rule (the shim would end up in the agent's allow_pypi_org_443). A network-only policy is refused on a live sandbox (filesystem policy cannot be removed on a live sandbox), so the static fields are sent back unchanged. No rule is ever left with an empty binaries list, whose meaning differs across OpenShell versions (depending on the release and on require_binary_identity, it matches no binary or any binary). Any policy change closes every open proxied connection in the sandbox, agent streams included, so switch only while nothing runs there.

The shim lets setup and validate reach preset hosts without widening the agent's egress. It is an extra layer, not isolation: it is not a boundary against the agent, which could run it if shim rules were live while one of its processes still ran. OpenShell does not kill the processes an exec leaves behind and has no per-exec cgroup or network profile (upstream suggests separate sandboxes or an in-sandbox supervisor instead), so each switch also does the following, and fails (RuntimeError) rather than open a phase it could not prepare:

  1. Kill leftovers (sandbox.stop_leftover_processes(), every phase). openshell sandbox exec --no-login-shell runs /usr/bin/python3 -I -S -c <process scan> as the sandbox user. It SIGKILLs every process of that user, one by one (exec'd commands share the supervisor's process group 1, so a group kill would be kill(-1)), except the scan and its ancestors and the sandbox's main process, then scans again until nothing is left or 10 s pass. A /proc entry it cannot read for any reason but "gone" counts as a survivor. The main process (sleep infinity) is recorded by sandbox.find_main_process() right after sandbox create, as the only sandbox-user child of the supervisor (waiting up to 10 s for it to appear, and refusing a second candidate or anything but sleep infinity), and saved as main_process (PID and start time) in the sandbox identity file; a process that only looks like it, or reuses its PID, is killed. The supervisor exits when its entrypoint does, so if the recorded process is not running the record is stale: the scan then kills nothing and the switch fails. A profile sandbox without that record is recreated, and so is a reused one whose switch to agent fails.
  2. Detach or attach the credential provider (openai, api-key and vertex auth). Entering setup or validate first probes a fresh exec: unless the provider's variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, or for Vertex GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN or GOOGLE_VERTEX_AI_TOKEN) holds an openshell:resolve:env: placeholder, it runs openshell sandbox provider attach --wait --timeout 30 ci ci-gcp and waits for one, so that a detach is only ever confirmed as a change from an observed attach. Then it runs openshell sandbox provider detach --wait --timeout 30 ci ci-gcp before the policy switch and probes a fresh exec (up to 30 s in total) until the placeholder is gone. The supervisor installs a sandbox's provider environment and its credential bindings in one step, so from then on the proxy resolves no placeholder for the key, old ones included. It polls the gateway every 10 s; --wait returns once it has installed the change (about 10 s on v0.1.2), so the probe that follows normally confirms it at once and stays only as a bounded, fail-closed check. That probe covers injection only. The rule OpenShell composes from the provider profile (API host bound to the agent binaries) leaves with the policy the supervisor reloads in the same poll, right after the environment; because policy set --wait is issued after the detach, it returns only once a policy the gateway composed without that rule is loaded, whenever the phase policy changes (always when leaving agent). Entering agent applies the policy first, then openshell sandbox provider attach --wait ... and waits until the placeholder is back. Both commands are idempotent (repeating one returns at once) and are cut off after 45 s, so a reused sandbox left detached is attached again by the switch to agent in setup(). The placeholder a fresh exec gets is the same before the detach and after the attach on v0.1.2 and v0.1.6, so one an agent stored in the agent phase (Codex's auth.json, a Vertex bearer token) keeps resolving. Vertex is detached too: its profile declares the aiplatform hosts, so OpenShell composes a rule for them that parking cannot touch. tests/e2e/e2e-openshell-profile.sh checks the detach, the placeholder probe and the re-attach end to end: section 8 with Codex (openai auth), section 9 with Claude Code (api-key auth), section 11 with Claude Code and a Vertex provider holding a fake token.

Detaching closes the provider's route to the API host and its injection, not the credential itself. With api-key auth the agent already holds the real key, because the env script exports it (until RHAI-3011 is fixed); it could leave it in the workdir, where setup or validate code, which it can also write, could send it out through the shim's egress. With openai and vertex auth the agent only ever sees the placeholder.

Order: setup/validate kill, probe (attach and wait if not seen attached), detach, policy set, wait for the detach; agent kill, policy set, attach, wait for the attach.

Every internal exec (the scan, the main process finder, the provider probe) passes --no-login-shell. Without it OpenShell runs the command as bash -lc, which first sources the agent-writable ~/.bash_profile, where a shell function named /usr/bin/python3 could forge the scan's or the probe's output, or an EXIT trap could start a daemon after the scan. A supervisor too old to honor the flag refuses the exec, and the switch fails.

OpenShell Artifacts

OpenShell is consumed from UBI9-based artifacts. All four components are pinned to the same version (v0.1.6-rhaiv.4) via OPENSHELL_IMAGE_TAG in images/ci/Containerfile.openshell:

Component Source How it is consumed
CLI (openshell) quay.io/opendatahub/odh-openshell-cli binary copied into the CI image via a multi-stage COPY --from
Gateway (openshell-gateway) quay.io/opendatahub/odh-openshell-gateway binary copied into the CI image via a multi-stage COPY --from
Supervisor (openshell-supervisor) quay.io/opendatahub/odh-openshell-supervisor pulled at runtime by the gateway's podman driver; runs in its own container beside each sandbox
Sandbox runtime (openshell-sandbox) quay.io/opendatahub/odh-openshell-sandbox pulled at runtime by the gateway's podman driver; the static binary is mounted read-only into each workload container

The supervisor/sandbox split landed upstream in NVIDIA/OpenShell#2942 and midstream in opendatahub-io/openshell#44; the first midstream tag with all four images is v0.0.116-rhaiv.8. The workload runs with network=none; since v0.1.0 the podman driver writes its /etc/resolv.conf (nameserver 127.0.0.53, the policy DNS relay), so the sandbox images bake none.

The OpenShell CI image (Containerfile.openshell) uses a UBI9 final stage. The sandbox images build directly on their hardened Hummingbird agentic images and use the matching digest-pinned Hummingbird builder to install python3 and supporting tools through a temporary dnf mount. The final sandbox images retain no package manager. The podman backend images (Containerfile.podman, Containerfile.base) are unaffected and remain on UBI10. scripts/bump-versions.py bumps OPENSHELL_IMAGE_TAG to the highest release tag that is published for all four component images, so the CI image never points at a tag whose runtime images do not exist yet. Renovate does not make that check: it proposes a new OPENSHELL_IMAGE_TAG as soon as odh-openshell-cli publishes it. Before merging such a PR, confirm that odh-openshell-gateway, odh-openshell-supervisor and odh-openshell-sandbox carry the same tag (for example with scripts/bump-versions.py --check --tool openshell, which reports the highest tag all four share).

Supervisor and Sandbox Runtime Images

Each sandbox is made of two containers plus a mounted binary:

  • The supervisor (openshell-supervisor) runs in its own container next to the workload and enforces network policy and credential injection. Its image is quay.io/opendatahub/odh-openshell-supervisor, set via OPENSHELL_SUPERVISOR_IMAGE in the CI image. Its detailed log is /var/log/openshell.<date>.log inside that container; podman logs shows only warnings and errors.
  • The sandbox runtime (openshell-sandbox) is a statically linked binary that runs inside the workload container. The podman driver pulls quay.io/opendatahub/odh-openshell-sandbox (set via OPENSHELL_SANDBOX_RUNTIME_IMAGE in the CI image) and mounts the binary read-only into every sandbox. Without this setting the gateway falls back to ghcr.io/nvidia/openshell/sandbox, which is not reachable from CI.

To override either image, set the corresponding environment variable before running agentic-ci. They are written into the gateway's TOML config under [openshell.drivers.podman] as supervisor_image and sandbox_runtime_image. Both images must carry the same tag as the CLI and gateway binaries baked into the CI image.

Known Issues and Workarounds

--binary "*" does not work for CONNECT requests

The wildcard * in openshell policy update --binary "*" fails to match binaries making HTTPS CONNECT tunnel requests. Use explicit paths instead:

--binary /usr/local/bin/claude --binary /usr/bin/opencode

--from-gcloud-adc rejects service account keys

The Vertex provider's --from-gcloud-adc flag only accepts user OAuth credentials (from gcloud auth application-default login). Service account JSON keys must be configured via the three-step create + refresh configure + rotate flow described above.

Credential refresh worker does not mint initial tokens

After openshell provider refresh configure, the gateway's refresh worker runs on a 60-second interval. Without an explicit openshell provider refresh rotate, the agent may start before the first token is minted. Always call rotate after configure for service accounts.

OPENSHELL_SUPERVISOR_IMAGE and OPENSHELL_SANDBOX_RUNTIME_IMAGE are not gateway env vars

The gateway binary does not read OPENSHELL_SUPERVISOR_IMAGE or OPENSHELL_SANDBOX_RUNTIME_IMAGE from the environment. It reads supervisor_image and sandbox_runtime_image from the [openshell.drivers.podman] section of gateway.toml. The env vars are a convention used by agentic-ci (and OpenShell's own dev scripts) to pass the image names into config generation.