Skip to content

Commit cf34dd5

Browse files
ericksoaclaude
andcommitted
feat: introduce sandbox traits concept, move capability-ratchet to traits/
Traits are cross-cutting capabilities you compose into any sandbox — not sandboxes themselves. This moves capability-ratchet from sandboxes/ to traits/ as the first trait, adds the trait.yaml manifest convention, TRAITS.md spec, CI support for building trait images, and updates README.md and CONTRIBUTING.md. Depends on #3. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 923a2f9 commit cf34dd5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+325
-6
lines changed

.github/workflows/build-sandboxes.yml

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
name: Build Sandbox Images
4+
name: Build Sandbox Images and Traits
55

66
on:
77
push:
88
branches: [main]
99
paths:
1010
- "sandboxes/**"
11+
- "traits/**"
1112
pull_request:
1213
branches: [main]
1314
paths:
1415
- "sandboxes/**"
16+
- "traits/**"
1517
workflow_dispatch:
1618

1719
env:
@@ -24,20 +26,21 @@ permissions:
2426

2527
jobs:
2628
# ---------------------------------------------------------------------------
27-
# Detect which sandbox images have changed
29+
# Detect which sandbox images and traits have changed
2830
# ---------------------------------------------------------------------------
2931
detect-changes:
30-
name: Detect changed sandboxes
32+
name: Detect changed sandboxes and traits
3133
runs-on: ubuntu-latest
3234
outputs:
3335
base-changed: ${{ steps.changes.outputs.base_changed }}
3436
sandboxes: ${{ steps.changes.outputs.sandboxes }}
37+
traits: ${{ steps.changes.outputs.traits }}
3538
steps:
3639
- uses: actions/checkout@v4
3740
with:
3841
fetch-depth: 0
3942

40-
- name: Determine changed sandboxes
43+
- name: Determine changed sandboxes and traits
4144
id: changes
4245
run: |
4346
set -euo pipefail
@@ -49,14 +52,17 @@ jobs:
4952
| xargs -I{} basename {} \
5053
| grep -v '^base$' \
5154
| jq -R -s -c 'split("\n") | map(select(length > 0))')
55+
TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \
56+
| xargs -I{} basename {} \
57+
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
5258
else
5359
if [ "${{ github.event_name }}" = "pull_request" ]; then
5460
BASE_SHA="${{ github.event.pull_request.base.sha }}"
5561
else
5662
BASE_SHA="${{ github.event.before }}"
5763
fi
5864
59-
CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/)
65+
CHANGED=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" -- sandboxes/ traits/)
6066
6167
# Check if base changed
6268
if echo "$CHANGED" | grep -q '^sandboxes/base/'; then
@@ -66,22 +72,37 @@ jobs:
6672
| xargs -I{} basename {} \
6773
| grep -v '^base$' \
6874
| jq -R -s -c 'split("\n") | map(select(length > 0))')
75+
# Also rebuild all traits (they depend on base too)
76+
TRAITS=$(find traits -mindepth 1 -maxdepth 1 -type d -exec test -f {}/Dockerfile \; -print \
77+
| xargs -I{} basename {} \
78+
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
6979
else
7080
BASE_CHANGED="false"
7181
SANDBOXES=$(echo "$CHANGED" \
82+
| grep '^sandboxes/' \
7283
| cut -d'/' -f2 \
7384
| sort -u \
7485
| while read -r name; do
7586
if [ "$name" != "base" ] && [ -f "sandboxes/${name}/Dockerfile" ]; then echo "$name"; fi
7687
done \
7788
| jq -R -s -c 'split("\n") | map(select(length > 0))')
89+
TRAITS=$(echo "$CHANGED" \
90+
| grep '^traits/' \
91+
| cut -d'/' -f2 \
92+
| sort -u \
93+
| while read -r name; do
94+
if [ -f "traits/${name}/Dockerfile" ]; then echo "$name"; fi
95+
done \
96+
| jq -R -s -c 'split("\n") | map(select(length > 0))' 2>/dev/null || echo '[]')
7897
fi
7998
fi
8099
81100
echo "base_changed=${BASE_CHANGED}" >> "$GITHUB_OUTPUT"
82101
echo "sandboxes=${SANDBOXES}" >> "$GITHUB_OUTPUT"
102+
echo "traits=${TRAITS}" >> "$GITHUB_OUTPUT"
83103
echo "Base changed: ${BASE_CHANGED}"
84-
echo "Will build: ${SANDBOXES}"
104+
echo "Will build sandboxes: ${SANDBOXES}"
105+
echo "Will build traits: ${TRAITS}"
85106
86107
# ---------------------------------------------------------------------------
87108
# Build the base sandbox image (other sandboxes depend on this)
@@ -217,3 +238,85 @@ jobs:
217238
BASE_IMAGE=${{ steps.base.outputs.image }}
218239
cache-from: type=gha,scope=${{ matrix.sandbox }}
219240
cache-to: type=gha,mode=max,scope=${{ matrix.sandbox }}
241+
242+
# ---------------------------------------------------------------------------
243+
# Build trait images (after base completes)
244+
# ---------------------------------------------------------------------------
245+
build-traits:
246+
name: Build trait ${{ matrix.trait }}
247+
needs: [detect-changes, build-base]
248+
if: |
249+
always() &&
250+
needs.detect-changes.result == 'success' &&
251+
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') &&
252+
needs.detect-changes.outputs.traits != '[]'
253+
runs-on: ubuntu-latest
254+
strategy:
255+
fail-fast: false
256+
matrix:
257+
trait: ${{ fromJson(needs.detect-changes.outputs.traits) }}
258+
steps:
259+
- uses: actions/checkout@v4
260+
261+
- name: Lowercase image prefix
262+
id: repo
263+
run: echo "image_prefix=${IMAGE_PREFIX,,}" >> "$GITHUB_OUTPUT"
264+
265+
- name: Set up QEMU
266+
uses: docker/setup-qemu-action@v3
267+
268+
- name: Start local registry (PR only)
269+
if: github.ref != 'refs/heads/main'
270+
run: docker run -d -p 5000:5000 --name registry registry:2
271+
272+
- name: Set up Docker Buildx
273+
uses: docker/setup-buildx-action@v3
274+
with:
275+
driver-opts: ${{ github.ref != 'refs/heads/main' && 'network=host' || '' }}
276+
277+
- name: Log in to GHCR
278+
uses: docker/login-action@v3
279+
with:
280+
registry: ${{ env.REGISTRY }}
281+
username: ${{ github.actor }}
282+
password: ${{ secrets.GITHUB_TOKEN }}
283+
284+
- name: Build base image locally (PR only)
285+
if: github.ref != 'refs/heads/main'
286+
uses: docker/build-push-action@v6
287+
with:
288+
context: sandboxes/base
289+
push: true
290+
tags: localhost:5000/sandboxes/base:latest
291+
cache-from: type=gha,scope=base
292+
293+
- name: Set BASE_IMAGE
294+
id: base
295+
run: |
296+
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
297+
echo "image=${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/sandboxes/base:latest" >> "$GITHUB_OUTPUT"
298+
else
299+
echo "image=localhost:5000/sandboxes/base:latest" >> "$GITHUB_OUTPUT"
300+
fi
301+
302+
- name: Generate image metadata
303+
id: meta
304+
uses: docker/metadata-action@v5
305+
with:
306+
images: ${{ env.REGISTRY }}/${{ steps.repo.outputs.image_prefix }}/traits/${{ matrix.trait }}
307+
tags: |
308+
type=sha,prefix=
309+
type=raw,value=latest,enable={{is_default_branch}}
310+
311+
- name: Build and push
312+
uses: docker/build-push-action@v6
313+
with:
314+
context: traits/${{ matrix.trait }}
315+
platforms: ${{ github.ref == 'refs/heads/main' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
316+
push: ${{ github.ref == 'refs/heads/main' }}
317+
tags: ${{ steps.meta.outputs.tags }}
318+
labels: ${{ steps.meta.outputs.labels }}
319+
build-args: |
320+
BASE_IMAGE=${{ steps.base.outputs.image }}
321+
cache-from: type=gha,scope=trait-${{ matrix.trait }}
322+
cache-to: type=gha,mode=max,scope=trait-${{ matrix.trait }}

CONTRIBUTING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ Requirements:
3838
- A `README.md` describing the sandbox's purpose, usage, and any prerequisites
3939
- Keep images minimal -- only include what's needed for the workload
4040

41+
## Adding a Trait
42+
43+
Traits are cross-cutting capabilities that can be composed into any sandbox. Each trait lives under `traits/`:
44+
45+
```
46+
traits/my-trait/
47+
Dockerfile
48+
trait.yaml
49+
README.md
50+
...
51+
```
52+
53+
Requirements:
54+
- A `Dockerfile` that builds the trait's artifacts (binaries, configs, scripts)
55+
- A `trait.yaml` manifest declaring what the trait exports, its startup script, ports, and network policy entries
56+
- A `README.md` describing the trait, its architecture, and usage
57+
58+
The `trait.yaml` format is documented in [TRAITS.md](TRAITS.md). Key sections:
59+
- `exports` -- paths to binaries, configs, scripts, and workspace directories the trait provides
60+
- `startup` -- the script to run and a health check URL
61+
- `network_policy` -- entries that consuming sandboxes should merge into their own `policy.yaml`
62+
63+
After adding your trait, update the "Available Traits" table in [TRAITS.md](TRAITS.md).
64+
4165
## Adding a Skill
4266

4367
Skills live inside their sandbox's `skills/` directory (e.g., `sandboxes/openclaw/skills/my-skill/`). Each skill should include:

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This repo is the community ecosystem around NemoClaw -- a hub for contributed sk
1010
| ------------ | --------------------------------------------------------------------------------- |
1111
| `brev/` | [Brev](https://brev.dev) launchable for one-click cloud deployment of NemoClaw |
1212
| `sandboxes/` | Pre-built sandbox images for domain-specific workloads (each with its own skills) |
13+
| `traits/` | Cross-cutting capabilities you compose into any sandbox (see [TRAITS.md](TRAITS.md)) |
1314

1415
### Sandboxes
1516

@@ -20,6 +21,14 @@ This repo is the community ecosystem around NemoClaw -- a hub for contributed sk
2021
| `sandboxes/openclaw/` | OpenClaw -- open agent manipulation and control |
2122
| `sandboxes/simulation/` | General-purpose simulation sandboxes |
2223

24+
### Traits
25+
26+
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.
27+
28+
| Trait | Description |
29+
| ----- | ----------- |
30+
| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context |
31+
2332
## Getting Started
2433

2534
### Prerequisites

TRAITS.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Sandbox Traits
2+
3+
Traits are cross-cutting capabilities you add to any NemoClaw sandbox. A trait is **not** a sandbox — it's a property you compose into one.
4+
5+
"Give me openclaw **with capability ratcheting**."
6+
"Give me sdg **with observability tracing**."
7+
8+
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`.
9+
10+
## Available Traits
11+
12+
| Trait | Description |
13+
| ----- | ----------- |
14+
| [`capability-ratchet`](traits/capability-ratchet/) | Prevents AI agent data exfiltration by dynamically revoking capabilities when private/untrusted data enters the context |
15+
16+
## Using a Trait
17+
18+
### 1. Copy trait artifacts into your sandbox Dockerfile
19+
20+
Each trait publishes a container image to GHCR. Use multi-stage `COPY --from` to pull in its exports:
21+
22+
```dockerfile
23+
# Start from the base sandbox
24+
ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw-community/sandboxes/base:latest
25+
FROM ${BASE_IMAGE}
26+
27+
# --- Add the capability-ratchet trait ---
28+
ARG RATCHET_IMAGE=ghcr.io/nvidia/nemoclaw-community/traits/capability-ratchet:latest
29+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/
30+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/
31+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/
32+
COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/
33+
COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/
34+
35+
# ... your sandbox setup ...
36+
```
37+
38+
The paths to copy are declared in the trait's `trait.yaml` under `exports`.
39+
40+
### 2. Chain the startup script
41+
42+
Call the trait's startup script from your sandbox entrypoint:
43+
44+
```bash
45+
# Start the ratchet sidecar (runs in background)
46+
ratchet-start
47+
48+
# Then start your sandbox's own services
49+
exec your-sandbox-entrypoint
50+
```
51+
52+
### 3. Merge network policy entries
53+
54+
If your sandbox has a `policy.yaml`, add the trait's `network_policy` entries from `trait.yaml`:
55+
56+
```yaml
57+
network_policies:
58+
# Your existing policies...
59+
60+
# From capability-ratchet trait
61+
ratchet_sidecar:
62+
name: ratchet_sidecar
63+
endpoints:
64+
- { host: api.anthropic.com, port: 443 }
65+
- { host: api.openai.com, port: 443 }
66+
- { host: integrate.api.nvidia.com, port: 443 }
67+
binaries:
68+
- { path: /usr/local/bin/capability-ratchet-sidecar }
69+
70+
inference:
71+
allowed_routes:
72+
- ratchet
73+
```
74+
75+
### Full Example: OpenClaw with Capability Ratcheting
76+
77+
```dockerfile
78+
ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw-community/sandboxes/base:latest
79+
ARG RATCHET_IMAGE=ghcr.io/nvidia/nemoclaw-community/traits/capability-ratchet:latest
80+
81+
FROM ${BASE_IMAGE}
82+
83+
USER root
84+
85+
# --- Capability Ratchet trait ---
86+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/capability-ratchet-sidecar /usr/local/bin/
87+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/bash-ast /usr/local/bin/
88+
COPY --from=${RATCHET_IMAGE} /usr/local/bin/ratchet-start /usr/local/bin/
89+
COPY --from=${RATCHET_IMAGE} /app/ratchet-config.yaml /app/
90+
COPY --from=${RATCHET_IMAGE} /app/policy.yaml /app/
91+
RUN mkdir -p /sandbox/.ratchet && chown sandbox:sandbox /sandbox/.ratchet
92+
93+
# --- OpenClaw setup ---
94+
RUN npm install -g @anthropic/openclaw-cli
95+
96+
COPY entrypoint.sh /usr/local/bin/entrypoint
97+
RUN chmod +x /usr/local/bin/entrypoint
98+
99+
USER sandbox
100+
ENTRYPOINT ["/usr/local/bin/entrypoint"]
101+
```
102+
103+
Where `entrypoint.sh` chains the trait startup:
104+
105+
```bash
106+
#!/usr/bin/env bash
107+
set -euo pipefail
108+
ratchet-start # Start capability ratchet sidecar
109+
exec openclaw-start # Then start OpenClaw
110+
```
111+
112+
## `trait.yaml` Format
113+
114+
Every trait must include a `trait.yaml` manifest at its root. This declares what the trait provides and how a sandbox consumes it.
115+
116+
| Field | Type | Description |
117+
| ----- | ---- | ----------- |
118+
| `name` | string | Trait identifier (matches the directory name under `traits/`) |
119+
| `version` | string | Semantic version |
120+
| `description` | string | What the trait does |
121+
| `exports.binaries` | list | Executable paths installed by the trait |
122+
| `exports.config` | list | Configuration file paths |
123+
| `exports.scripts` | list | Startup/utility script paths |
124+
| `exports.workspace` | list | Directories created for runtime state |
125+
| `startup.script` | string | Path to the startup script |
126+
| `startup.health_check` | string | URL to check that the trait is running |
127+
| `ports` | list | Ports the trait listens on |
128+
| `network_policy` | object | Network policy entries to merge into the sandbox's `policy.yaml` |
129+
| `inference` | object | Inference routing configuration (route name + endpoint) |
130+
131+
## Creating a Trait
132+
133+
1. Create a directory under `traits/<name>/`
134+
2. Add a `Dockerfile` that builds the trait's artifacts
135+
3. Add a `trait.yaml` manifest declaring exports, startup, and policies
136+
4. Add a `README.md` describing the trait and its usage
137+
5. Add the trait to the table at the top of this file
138+
6. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full checklist
File renamed without changes.

0 commit comments

Comments
 (0)