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:
- Starts the OpenShell gateway with TLS and mTLS auth
- Creates a Vertex AI, Anthropic, or OpenAI credential provider from one of agentic-ci's provider profiles (none for a Claude subscription OAuth token)
- Creates a sandbox container from the specified image, with an explicit base policy
- Applies a network policy and waits for it to activate
- Runs the
.agentic-ci/config.ymlsetup steps on the host, only with the deprecated local opt-in--allow-host-setup(see Setup Steps) - Uploads the workdir (including setup step outputs) into the sandbox
- 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) - Uploads an env script with agent configuration
- Executes the agent inside the sandbox
- Runs the sandbox profile's validate commands in the sandbox and removes
its
discard_before_downloadpaths (see Run order) - Downloads the workdir back to the host and restores the host's git control files (see Host git after the run)
- 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.
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 -kon the host showsMemory 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 -Linside 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:
sandbox.createwith the resources and the agent-phase egress (with a sandbox profile, its main process is recorded right after).- 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. sandbox.upload(workdir).-
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 thesetupegress 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'senv, no credential), record them in_run/sandbox-setup.json, then switch to theagentphase (see Setup, validation and records). A failed or timed-out step is recorded and the next one still runs. 6./sandbox/.agentic-ci/ENVIRONMENT.mdandenvironment.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():
- Switches the sandbox to the
agentphase 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. - Validate commands, when the profile has them: switch to the
validatephase, which kills every leftover process, then deletes the harness credential files (Codex'sauth.jsonand 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 theagentphase. 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. - Moves the
discard_before_downloadpaths 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. - 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_runthe 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:
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:
- Kill leftovers (
sandbox.stop_leftover_processes(), every phase).openshell sandbox exec --no-login-shellruns/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 bekill(-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/procentry it cannot read for any reason but "gone" counts as a survivor. The main process (sleep infinity) is recorded bysandbox.find_main_process()right aftersandbox 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 butsleep infinity), and saved asmain_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 toagentfails. - Detach or attach the credential provider (openai, api-key and vertex
auth). Entering
setuporvalidatefirst probes a fresh exec: unless the provider's variable (OPENAI_API_KEY,ANTHROPIC_API_KEY, or for VertexGOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKENorGOOGLE_VERTEX_AI_TOKEN) holds anopenshell:resolve:env:placeholder, it runsopenshell sandbox provider attach --wait --timeout 30 ci ci-gcpand waits for one, so that a detach is only ever confirmed as a change from an observed attach. Then it runsopenshell sandbox provider detach --wait --timeout 30 ci ci-gcpbefore 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;--waitreturns 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; becausepolicy set --waitis 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 leavingagent). Enteringagentapplies the policy first, thenopenshell 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 toagentinsetup(). 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'sauth.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.shchecks 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 isquay.io/opendatahub/odh-openshell-supervisor, set viaOPENSHELL_SUPERVISOR_IMAGEin the CI image. Its detailed log is/var/log/openshell.<date>.loginside that container;podman logsshows only warnings and errors. - The sandbox runtime (
openshell-sandbox) is a statically linked binary that runs inside the workload container. The podman driver pullsquay.io/opendatahub/odh-openshell-sandbox(set viaOPENSHELL_SANDBOX_RUNTIME_IMAGEin the CI image) and mounts the binary read-only into every sandbox. Without this setting the gateway falls back toghcr.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:
--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.