Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 108 additions & 6 deletions .github/workflows/build-sandboxes.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

name: Build Sandbox Images
name: Build Sandbox Images and Traits

on:
push:
branches: [main]
paths:
- "sandboxes/**"
- "traits/**"
pull_request:
branches: [main]
paths:
- "sandboxes/**"
- "traits/**"
workflow_dispatch:

env:
Expand All @@ -24,20 +26,21 @@ permissions:

jobs:
# ---------------------------------------------------------------------------
# Detect which sandbox images have changed
# Detect which sandbox images and traits have changed
# ---------------------------------------------------------------------------
detect-changes:
name: Detect changed sandboxes
name: Detect changed sandboxes and traits
runs-on: ubuntu-latest
outputs:
base-changed: ${{ steps.changes.outputs.base_changed }}
sandboxes: ${{ steps.changes.outputs.sandboxes }}
traits: ${{ steps.changes.outputs.traits }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Determine changed sandboxes
- name: Determine changed sandboxes and traits
id: changes
run: |
set -euo pipefail
Expand All @@ -49,14 +52,17 @@ jobs:
| xargs -I{} basename {} \
| grep -v '^base$' \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \
| xargs -I{} basename {} \
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
else
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi

CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/)
CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/ traits/)

# Check if base changed
if echo "$CHANGED" | grep -q '^sandboxes/base/'; then
Expand All @@ -66,22 +72,37 @@ jobs:
| xargs -I{} basename {} \
| grep -v '^base$' \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
# Also rebuild all traits (they depend on base too)
TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \
| xargs -I{} basename {} \
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
else
BASE_CHANGED="false"
SANDBOXES=$(echo "$CHANGED" \
| grep '^sandboxes/' \
| cut -d'/' -f2 \
| sort -u \
| while read -r name; do
if [ "$name" != "base" ] && [ -f "sandboxes/${name}/Dockerfile" ]; then echo "$name"; fi
done \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
TRAITS=$(echo "$CHANGED" \
| grep '^traits/' \
| cut -d'/' -f2 \
| sort -u \
| while read -r name; do
if [ -f "traits/${name}/Dockerfile" ]; then echo "$name"; fi
done \
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
fi
fi

echo "base_changed=${BASE_CHANGED}" >> "$GITHUB_OUTPUT"
echo "sandboxes=${SANDBOXES}" >> "$GITHUB_OUTPUT"
echo "traits=${TRAITS}" >> "$GITHUB_OUTPUT"
echo "Base changed: ${BASE_CHANGED}"
echo "Will build: ${SANDBOXES}"
echo "Will build sandboxes: ${SANDBOXES}"
echo "Will build traits: ${TRAITS}"

# ---------------------------------------------------------------------------
# Build the base sandbox image (other sandboxes depend on this)
Expand Down Expand Up @@ -245,3 +266,84 @@ jobs:
cache-to: type=gha,mode=max,scope=${{ matrix.sandbox }}


# ---------------------------------------------------------------------------
# Build trait images (after base completes)
# ---------------------------------------------------------------------------
build-traits:
name: Build trait ${{ matrix.trait }}
needs: [detect-changes, build-base]
if: |
always() &&
needs.detect-changes.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') &&
needs.detect-changes.outputs.traits != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
trait: ${{ fromJson(needs.detect-changes.outputs.traits) }}
steps:
- uses: actions/checkout@v4

- name: Lowercase image prefix
id: repo
run: echo "image_prefix=${IMAGE_PREFIX,,}" >> "$GITHUB_OUTPUT"

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Start local registry (PR only)
if: github.ref != 'refs/heads/main'
run: docker run -d -p 5000:5000 --name registry registry:2

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: ${{ github.ref != 'refs/heads/main' && 'network=host' || '' }}

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build base image locally (PR only)
if: github.ref != 'refs/heads/main'
uses: docker/build-push-action@v6
with:
context: sandboxes/base
push: true
tags: localhost:5000/sandboxes/base:latest
cache-from: type=gha,scope=base

- name: Set BASE_IMAGE
id: base
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/base:latest" >> "$GITHUB_OUTPUT"
else
echo "image=localhost:5000/sandboxes/base:latest" >> "$GITHUB_OUTPUT"
fi

- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/traits/${{ matrix.trait }}
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: traits/${{ matrix.trait }}
platforms: ${{ github.ref == 'refs/heads/main' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
BASE_IMAGE=${{ steps.base.outputs.image }}
cache-from: type=gha,scope=trait-${{ matrix.trait }}
cache-to: type=gha,mode=max,scope=trait-${{ matrix.trait }}
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ Requirements:
- A `README.md` describing the sandbox's purpose, usage, and any prerequisites
- Keep images minimal -- only include what's needed for the workload

## Adding a Trait

Traits are cross-cutting capabilities that can be composed into any sandbox. Each trait lives under `traits/`:

```
traits/my-trait/
Dockerfile
trait.yaml
README.md
...
```

Requirements:
- A `Dockerfile` that builds the trait's artifacts (binaries, configs, scripts)
- A `trait.yaml` manifest declaring what the trait exports, its startup script, ports, and network policy entries
- A `README.md` describing the trait, its architecture, and usage

The `trait.yaml` format is documented in [TRAITS.md](TRAITS.md). Key sections:
- `exports` -- paths to binaries, configs, scripts, and workspace directories the trait provides
- `startup` -- the script to run and a health check URL
- `network_policy` -- entries that consuming sandboxes should merge into their own `policy.yaml`

After adding your trait, update the "Available Traits" table in [TRAITS.md](TRAITS.md).

## Adding a Skill

Skills live inside their sandbox's `skills/` directory (e.g., `sandboxes/openclaw/skills/my-skill/`). Each skill should include:
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s
| ------------ | --------------------------------------------------------------------------------- |
| `brev/` | [Brev](https://brev.dev) launchable for one-click cloud deployment of OpenShell |
| `sandboxes/` | Pre-built sandbox images for domain-specific workloads (each with its own skills) |
| `traits/` | Cross-cutting capabilities you compose into any sandbox (see [TRAITS.md](TRAITS.md)) |

### Sandboxes

Expand All @@ -20,6 +21,14 @@ This repo is the community ecosystem around OpenShell -- a hub for contributed s
| `sandboxes/openclaw/` | OpenClaw -- open agent manipulation and control |
| `sandboxes/simulation/` | General-purpose simulation sandboxes |

### Traits

Traits are cross-cutting capabilities you add to any sandbox -- not sandboxes themselves. Each trait ships as a Docker image; you compose it into your sandbox via `COPY --from` at build time. See [TRAITS.md](TRAITS.md) for the full specification.

| Trait | Description |
| ----- | ----------- |
| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context |

## Getting Started

### Prerequisites
Expand Down
141 changes: 141 additions & 0 deletions TRAITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Sandbox Traits

Traits are cross-cutting capabilities you add to any OpenShell sandbox. A trait is **not** a sandbox — it's a property you compose into one.

"Give me openclaw **with capability ratcheting**."
"Give me sdg **with observability tracing**."

Each trait ships as a Docker image containing its binaries, configs, and startup script. You compose a trait into your sandbox at build time using Docker multi-stage `COPY --from`.

## Available Traits

| Trait | Description |
| ----- | ----------- |
| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context |

## Using a Trait

### 1. Copy trait artifacts into your sandbox Dockerfile

Each trait publishes a container image to GHCR. Use multi-stage `COPY --from` to pull in its exports:

```dockerfile
# Start from the base sandbox
ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest
FROM ${BASE_IMAGE}

# --- Add the capability-ratchet trait ---
ARG RATCHET_IMAGE=ghcr.io/nvidia/openshell-community/traits/capability-ratchet:latest
COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/
COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/

# ... your sandbox setup ...
```

The paths to copy are declared in the trait's `trait.yaml` under `exports`.

### 2. Chain the startup script

Call the trait's startup script from your sandbox entrypoint:

```bash
# Start the ratchet sidecar (runs in background)
ratchet-start

# Then start your sandbox's own services
exec your-sandbox-entrypoint
```

### 3. Merge network policy entries

If your sandbox has a `policy.yaml`, add the trait's `network_policy` entries from `trait.yaml`:

```yaml
network_policies:
# Your existing policies...

# From capability-ratchet trait
ratchet_sidecar:
name: ratchet_sidecar
endpoints:
- { host: api.anthropic.com, port: 443 }
- { host: api.openai.com, port: 443 }
- { host: integrate.api.nvidia.com, port: 443 }
binaries:
- { path: /usr/local/bin/capability-ratchet-sidecar }

inference:
allowed_routes:
- ratchet
```

### Full Example: OpenClaw with Capability Ratcheting

```dockerfile
ARG BASE_IMAGE=ghcr.io/nvidia/openshell-community/sandboxes/base:latest
ARG RATCHET_IMAGE=ghcr.io/nvidia/openshell-community/traits/capability-ratchet:latest

FROM ${BASE_IMAGE}

USER root

# --- Capability Ratchet trait ---
COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/
COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/
COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/
RUN mkdir -p /sandbox/.ratchet && chown sandbox:sandbox /sandbox/.ratchet

# --- OpenClaw setup ---
RUN npm install -g openclaw

COPY entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint

USER sandbox
ENTRYPOINT ["/usr/local/bin/entrypoint"]
```

Where `entrypoint.sh` chains the trait startup:

```bash
#!/usr/bin/env bash
set -euo pipefail
ratchet-start # Start capability ratchet sidecar
exec openclaw-start # Then start OpenClaw
```

## `trait.yaml` Format

Every trait must include a `trait.yaml` manifest at its root. This declares what the trait provides and how a sandbox consumes it.

| Field | Type | Description |
| ----- | ---- | ----------- |
| `name` | string | Trait identifier (matches the directory name under `traits/`) |
| `version` | string | Semantic version |
| `description` | string | What the trait does |
| `exports.binaries` | list | Executable paths installed by the trait |
| `exports.config` | list | Configuration file paths |
| `exports.scripts` | list | Startup/utility script paths |
| `exports.workspace` | list | Directories created for runtime state |
| `startup.script` | string | Path to the startup script |
| `startup.health_check` | string | URL to check that the trait is running |
| `ports` | list | Ports the trait listens on |
| `network_policy` | object | Network policy entries to merge into the sandbox's `policy.yaml` |
| `inference` | object | Inference routing configuration (route name + endpoint) |

## Creating a Trait

1. Create a directory under `traits/<name>/`
2. Add a `Dockerfile` that builds the trait's artifacts
3. Add a `trait.yaml` manifest declaring exports, startup, and policies
4. Add a `README.md` describing the trait and its usage
5. Add the trait to the table at the top of this file
6. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full checklist
1 change: 1 addition & 0 deletions traits/capability-ratchet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
Loading