Skip to content

OpenShell

Backend

openshell

OpenShell sandbox backend for agentic-ci.

OpenShellBackend(workdir='.', image=None, policy=None, extra_env=None, approval_mode=None, *, harness)

Bases: Backend

Runs an AI agent inside an OpenShell sandbox.

OpenShell provides security-focused sandboxing with network policy enforcement, filesystem isolation, and Landlock-based access control. Authentication is handled through the OpenShell google-cloud provider, which injects GCP credentials via the supervisor proxy. The agent uses its native Vertex AI integration directly.

Unlike PodmanBackend, which bind-mounts the workdir so changes are visible immediately on the host, OpenShellBackend copies the workdir into the sandbox on setup() and copies it back after run() completes. Only changes inside the workdir are reflected back to the host; files written elsewhere in the sandbox (e.g. /tmp) are not retrieved.

Source code in src/agentic_ci/backends/openshell/__init__.py
def __init__(
    self,
    workdir=".",
    image=None,
    policy=None,
    extra_env=None,
    approval_mode=None,
    *,
    harness: Harness,
):
    super().__init__(workdir=workdir, image=image, harness=harness)
    self.policy_path = policy
    self._extra_env = extra_env or {}
    self.approval_mode = approval_mode

Gateway

gateway

OpenShell gateway lifecycle management.

is_running()

Check if the OpenShell gateway is registered and healthy.

Source code in src/agentic_ci/backends/openshell/gateway.py
def is_running():
    """Check if the OpenShell gateway is registered and healthy."""
    try:
        cmd = ["openshell", "status"]
        log.detail("exec", " ".join(cmd))
        result = subprocess.run(cmd, capture_output=True, timeout=10, text=True)
        if result.returncode != 0:
            return False
        return "No gateway configured" not in result.stdout
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False

start()

Start the OpenShell gateway with the podman driver.

Starts the podman API socket, generates TLS certificates for sandbox JWT auth, writes a gateway config, launches openshell-gateway in the background, registers it with the CLI, and blocks until the health endpoint responds.

If any step fails after processes have been spawned, cleanup is performed automatically to avoid orphaned processes.

Source code in src/agentic_ci/backends/openshell/gateway.py
def start():
    """Start the OpenShell gateway with the podman driver.

    Starts the podman API socket, generates TLS certificates for sandbox
    JWT auth, writes a gateway config, launches openshell-gateway in the
    background, registers it with the CLI, and blocks until the health
    endpoint responds.

    If any step fails after processes have been spawned, cleanup is
    performed automatically to avoid orphaned processes.
    """
    xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
    sock = f"{xdg}/podman/podman.sock"
    os.makedirs(f"{xdg}/podman", exist_ok=True)

    subprocess.Popen(
        ["podman", "system", "service", "--time=0", f"unix://{sock}"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )

    try:
        _wait_for_socket(sock)
        _write_config()
        _generate_certs()

        supervisor_image = os.environ.get("OPENSHELL_SUPERVISOR_IMAGE")
        if supervisor_image:
            print(f"  Supervisor image: {supervisor_image}", flush=True)

        state_dir = os.path.expanduser("~/.local/state/openshell")
        os.makedirs(state_dir, exist_ok=True)
        log_file = tempfile.NamedTemporaryFile(
            mode="w",
            dir=state_dir,
            prefix="gateway-",
            suffix=".log",
            delete=False,
        )
        subprocess.Popen(
            [
                "openshell-gateway",
                "--db-url",
                "sqlite::memory:",
                "--log-level",
                "info",
            ],
            stdout=log_file,
            stderr=subprocess.STDOUT,
        )
        log_file.close()

        _register()

        for _ in range(30):
            if is_running():
                return
            time.sleep(2)

        raise RuntimeError("Gateway did not become healthy within 60s")

    except Exception:
        stop()
        raise

stop()

Terminate the gateway and podman service processes.

Deregisters the gateway from the CLI first, then discovers and kills processes by port and socket rather than requiring stored handles, so this works across process boundaries (e.g. a separate agentic-ci stop invocation).

Source code in src/agentic_ci/backends/openshell/gateway.py
def stop():
    """Terminate the gateway and podman service processes.

    Deregisters the gateway from the CLI first, then discovers and kills
    processes by port and socket rather than requiring stored handles, so
    this works across process boundaries (e.g. a separate
    ``agentic-ci stop`` invocation).
    """
    # remove only clears CLI metadata, it does not stop the process
    try:
        cmd = ["openshell", "gateway", "remove", "ci"]
        log.detail("exec", " ".join(cmd))
        result = subprocess.run(cmd, capture_output=True, timeout=5, text=True)
        if result.returncode != 0 and result.stderr:
            print(f"  gateway remove: {result.stderr.strip()}", flush=True)
    except (FileNotFoundError, subprocess.TimeoutExpired):
        pass
    _kill_gateway()
    _kill_podman_service()

Sandbox

sandbox

OpenShell sandbox lifecycle management.

exists()

Check if the sandbox already exists.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def exists():
    """Check if the sandbox already exists."""
    result = _run(
        ["openshell", "sandbox", "get", SANDBOX_NAME],
        capture_output=True,
    )
    return result.returncode == 0

create(image=None, policy_path=None, otel_port=None, workdir='.', approval_mode=None)

Create a persistent sandbox with the CI provider attached.

The sandbox is created first, then the network policy is applied via openshell policy update --wait to ensure the supervisor has compiled and activated the rules before the agent starts.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def create(image=None, policy_path=None, otel_port=None, workdir=".", approval_mode=None):
    """Create a persistent sandbox with the CI provider attached.

    The sandbox is created first, then the network policy is applied
    via ``openshell policy update --wait`` to ensure the supervisor
    has compiled and activated the rules before the agent starts.
    """
    args = [
        "openshell",
        "sandbox",
        "create",
        "--name",
        SANDBOX_NAME,
        "--no-tty",
        "--no-auto-providers",
        "--provider",
        PROVIDER_NAME,
    ]
    if approval_mode:
        args.extend(["--approval-mode", approval_mode])
    if image:
        args.extend(["--from", image])
    args.extend(["--", "true"])
    _run(args, check=True)

    if approval_mode:
        _run(
            [
                "openshell",
                "settings",
                "set",
                SANDBOX_NAME,
                "--key",
                "agent_policy_proposals_enabled",
                "--value",
                "true",
            ],
            check=True,
        )

    _apply_policy(policy_path, otel_port=otel_port, workdir=workdir)

upload(local_path)

Upload a local path into the sandbox.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def upload(local_path):
    """Upload a local path into the sandbox."""
    _run(
        ["openshell", "sandbox", "upload", "--no-git-ignore", SANDBOX_NAME, local_path],
        check=True,
    )

download(sandbox_path, local_dest)

Download a path from the sandbox to a local destination.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def download(sandbox_path, local_dest):
    """Download a path from the sandbox to a local destination."""
    _run(
        ["openshell", "sandbox", "download", SANDBOX_NAME, sandbox_path, local_dest],
        check=True,
    )

exec_cmd(cmd)

Run a command inside the sandbox. Returns the CompletedProcess.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def exec_cmd(cmd):
    """Run a command inside the sandbox. Returns the CompletedProcess."""
    return _run(
        ["openshell", "sandbox", "exec", "--name", SANDBOX_NAME, "--no-tty", "--"] + cmd,
        check=True,
    )

exec_cmd_streaming(cmd)

Run a command inside the sandbox with stdout piped. Returns a Popen.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def exec_cmd_streaming(cmd):
    """Run a command inside the sandbox with stdout piped. Returns a Popen."""
    args = ["openshell", "sandbox", "exec", "--name", SANDBOX_NAME, "--no-tty", "--"] + cmd
    log.detail("exec", " ".join(args))
    return subprocess.Popen(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )

delete()

Delete the sandbox.

Source code in src/agentic_ci/backends/openshell/sandbox.py
def delete():
    """Delete the sandbox."""
    _run(
        ["openshell", "sandbox", "delete", SANDBOX_NAME],
        check=True,
    )

Policy

policy

Policy resolution for OpenShell sandbox.

resolve_endpoints(flag_path=None, workdir='.')

Resolve the endpoint list to use for policy update.

Merges the built-in defaults with extra endpoints from, in priority order:

  1. Explicit --policy flag path
  2. .agentic-ci/openshell-policy.yml in workdir

Returns a list of endpoint strings for openshell policy update --add-endpoint.

Source code in src/agentic_ci/backends/openshell/policy.py
def resolve_endpoints(flag_path=None, workdir="."):
    """Resolve the endpoint list to use for policy update.

    Merges the built-in defaults with extra endpoints from, in priority
    order:

    1. Explicit ``--policy`` flag path
    2. ``.agentic-ci/openshell-policy.yml`` in *workdir*

    Returns a list of endpoint strings for ``openshell policy update --add-endpoint``.
    """
    extra = []
    source = "built-in default"

    if flag_path and os.path.isfile(flag_path):
        extra = _load_endpoints_from_file(flag_path)
        source = f"--policy flag ({os.path.abspath(flag_path)})"
    else:
        repo_path = os.path.join(workdir, REPO_POLICY_PATH)
        if os.path.isfile(repo_path):
            extra = _load_endpoints_from_file(repo_path)
            source = f"repo ({os.path.abspath(repo_path)})"

    print(f"  Policy source: {source}", flush=True)

    endpoints = list(DEFAULT_ENDPOINTS)
    seen = set(endpoints)
    for ep in extra:
        if ep not in seen:
            endpoints.append(ep)
            seen.add(ep)
    return endpoints