Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/security/CREDENTIAL_BROKER_EVALUATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ That last point is the opportunity hiding in plain sight. **Today sandy mounts t
5. **Docs/threat-model** — record the new credential trust boundary, the CA-key-host-side rule, and the issuer-cooperation reality (why raw API keys can't be short-lived without A2). (small)

## Cross-references
- [`nono-comparison.md`](nono-comparison.md) — [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) is a **shipped reference implementation** of this design (broker-not-mount, phantom tokens, L7 endpoint scoping, `cmd://` host-side OAuth capture, SPIFFE). It confirms the A2 trade-off here — injecting at the proxy requires terminating TLS — and its `cmd://` lazy-capture is a concrete blueprint for A1. Complementary to sandy (nono itself recommends a container/VM perimeter, which sandy provides).
- [`research/credential-broker-cb4a.md`](../../research/credential-broker-cb4a.md) — the source CB4A research; defines every CB4A term used here and carries the primary sources (IETF draft, Posta/Hartman blogs, NIST 800-207, RFC 9449).
- HF defender-side lessons (internal analysis, unpublished): "keep roots-of-trust out of the sandbox" (A1 implements it) and "externalized per-identity audit log" (A1's per-sandbox identity is its prerequisite).
- `CLAUDE.md` "Egress Proxy" / `proxy/config.go` (the no-MITM invariant A2 would fork), "Agent Selection" credential-probe orders (the per-agent secrets A1/A2 must cover: claude/gemini/codex/opencode/grok).
81 changes: 81 additions & 0 deletions docs/security/nono-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# nono vs. sandy — comparison and transferable lessons

**Subject:** [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) — a Rust CLI that sandboxes AI coding agents with **OS-native kernel primitives** (no daemon, no container, no VM), from the team behind Sigstore.
**Why this doc:** nono ships working implementations of two things sandy has only *evaluated* ([`CREDENTIAL_BROKER_EVALUATION.md`](CREDENTIAL_BROKER_EVALUATION.md) / milestone #12) or *roadmapped* (the fanotify `FAN_OPEN_PERM` idea in `CLAUDE.md` → "Protected Files"). It is **complementary to sandy, not competing** — this doc records why, and what to borrow.

> **Provenance / caveat.** Everything below is drawn from nono's own public docs (`nono.sh/docs`, `github.com/nolabs-ai/nono`), read-only, on 2026-08-04. These are nono's *claims*, not independently verified by us.

---

## 1. What nono is (mechanism)

A per-process sandbox enforced by **OS-native primitives**, per platform:

- **Linux:** **Landlock LSM** (an *irreversible* filesystem "floor" — `restrict_self()`, kernel 5.13+, needs **no root / no `CAP_SYS_ADMIN`**) **layered with seccomp-notify**. A **supervisor** — the unprivileged *parent* process after `fork()` — traps `openat`/`openat2`, decides allow/deny, and **injects the file descriptor itself** (`SECCOMP_IOCTL_NOTIF_ADDFD`). Landlock is the floor that catches supervisor bugs; seccomp-notify is the dynamic gate. Only `openat*` is trapped; `read`/`write`/`connect`/`stat` are governed by Landlock or left open.
- **macOS:** Apple **Seatbelt** (`sandbox-exec`).
- **Windows:** WSL2.

Plus a **credential proxy** (below), **SPIFFE** workload identity, per-tool **child sandboxes** (delegated `git`/`gh`/`curl` get their own policies "outside the agent's control"), JSON **capability manifests**, and **Sigstore** attestation.

## 2. The credential proxy (the most transferable piece)

A **localhost reverse proxy** that injects the real credential on the fly:

> *"The agent talks to a local reverse proxy that injects real API keys on the fly. The credential never enters the sandbox, not even as an environment variable."*

- The agent gets a **phantom token** — a 256-bit per-session placeholder in `NONO_PROXY_TOKEN`. It calls `http://127.0.0.1:<port>/openai/...`; the proxy validates the phantom token, swaps it for the real key, and forwards **over TLS** to `https://api.openai.com`. SDKs are steered via `OPENAI_BASE_URL`/`ANTHROPIC_BASE_URL`, no code change.
- **L7 scoping:** `endpoint_rules` restrict a credential to specific **HTTP method+path** combinations — the token only works against whitelisted endpoints.
- **OAuth / durable creds stay on the host:** a `cmd://` route runs e.g. `gh auth token` **on the host** lazily (first matching request pauses, supervisor runs the command, caches with a TTL); the sandbox receives only the phantom token. Sources include `op://`, `bw://`, `keyring://`, `env://`, `cmd://`.
- Creds live in `Zeroizing<String>`, never on disk, never logged.

## 3. The decisive framing: nono says to wrap it in a container

nono is unusually honest about its boundary, and this is the whole "how it applies to sandy" answer:

> *"Not Firecracker, not a hypervisor, and not a container runtime… does not provide separate kernel boundary, hardware-level memory isolation, or full filesystem namespace separation… The sandboxed process shares the host kernel."*

> *"For multi-tenant isolation, run nono inside a container or microVM. That adds a guest/host boundary that nono does not provide on its own."*

> Recommended high-assurance deployment: *"a lightweight VM (Firecracker) or hardened container runtime (Edera, Kata) for the outer perimeter, and nono inside for fine-grained capability control."*

**So sandy's Docker container is exactly the outer perimeter nono recommends.** They are complementary layers:

| | **sandy** | **nono** |
|---|---|---|
| Isolation primitive | Docker container: PID/net/mount/user **namespaces**, `--read-only`, cap-drop, seccomp | Landlock + seccomp-notify supervisor (Linux) / Seatbelt (macOS) — **shared kernel** |
| Boundary strength | Namespace/guest-ish boundary (stronger vs. a jailbroken agent; R1 kernel-escape residual) | Same-user containment; **explicitly not** a guest/host boundary |
| Network isolation | egress proxy sidecar (`--internal` topology, TLS **passthrough**) | localhost reverse proxy, TLS **terminating**, L7 endpoint rules |
| Credentials | mounted ephemerally into the container (agent holds them) | **broker-not-mount**: phantom token in sandbox, real cred never enters |
| Identity | per-sandbox marker (`sandy-session.json`) | **SPIFFE** SVIDs |
| Dependency | **requires Docker** | no daemon / no container / cross-platform native |
| Attestation | — | Sigstore |

The stack nono *recommends* — **container outside, capability/credential control inside** — is literally **sandy + nono's ideas**.

## 4. What sandy should borrow (maps to existing work)

1. **Credential proxy / phantom tokens — a shipped proof of milestone #12.** nono is a working reference implementation of `CREDENTIAL_BROKER_EVALUATION.md`'s broker-not-mount design, *including the trade-off we already flagged*: injecting a credential at the proxy **requires terminating TLS** (nono's proxy is TLS-terminating; sandy's egress proxy is deliberately TLS-**passthrough**). nono validates the eval's **A2** analysis (real, buildable, cost = the no-MITM invariant) and the **`cmd://` lazy-capture** is a clean blueprint for **A1** (durable cred stays host-side; sandbox gets only a phantom/short-lived token). *(→ issue filed under #12.)*
2. **`endpoint_rules` L7 scoping** = sandy's strict-mode host-relay broker (currently POST_1.0) — scoping a token to specific API paths closes "exfil to an *allowed* host" (THREAT_MODEL R3).
3. **seccomp-notify supervisor** = an in-the-wild instance of sandy's **fanotify `FAN_OPEN_PERM`** roadmap (true prevention, not detection-only, for protected paths).
4. **Per-tool child sandboxes** — narrower blast radius than sandy's one-container-runs-everything model. Longer-horizon.

## 5. What sandy should NOT do

Don't adopt nono's *isolation primitive*. For sandy's threat model (determined/jailbroken agent, R1), a shared-kernel process sandbox is a **weaker** boundary than a container — and nono agrees. The transferable value is nono's **credential/identity layer**, which is *orthogonal* to the isolation primitive and is exactly where sandy is behind.

One honest data point: nono's zero-daemon / no-container / cross-platform model would sidestep the entire class of Docker-dependency friction sandy has (e.g. the Proxmox generic-CPU build hang, #117). Not a reason to switch — a weaker boundary — but confirmation that sandy's hard Docker requirement is a real adoption cost, and nono is the proof-of-concept of "the sandy use-case minus Docker."

## 6. Better-together

Because the two occupy **different, non-overlapping layers**, there's a clean complementary story: **container/namespace perimeter (sandy) + per-open capability & broker-not-mount credentials (nono)**. A cross-recommendation ("sandy for the container boundary; nono inside for fine-grained capability/credential control — and nono itself recommends a container/VM perimeter, which sandy provides") is technically accurate and mutually reinforcing, not marketing spin — **but gated on validating that nono actually runs inside a sandy container** (`--read-only`, cap-dropped, `no-new-privileges`, under Docker's seccomp profile), which is not a given. See [`nono-roadmap.md`](nono-roadmap.md) for the sequenced plan (validation spike → positioning → longer-horizon borrows) and the tracking issue for the credential-proxy borrow.

## Sources
- https://github.com/nolabs-ai/nono
- https://nono.sh/docs/cli/internals/security-model.md (Landlock + seccomp-notify, supervisor, stated limitations)
- https://nono.sh/docs/cli/features/credential-injection.md (proxy, phantom tokens, L7, `cmd://`)
- https://nono.sh/docs/cli/internals/{landlock,seatbelt,containers}.md, features/{networking,spiffe,sandboxed-oauth-logins}.md

## Cross-references
- [`CREDENTIAL_BROKER_EVALUATION.md`](CREDENTIAL_BROKER_EVALUATION.md) — sandy's broker-not-mount evaluation (milestone #12); nono is its working reference implementation.
- [`research/credential-broker-cb4a.md`](../../research/credential-broker-cb4a.md) — the CB4A/SPIFFE research nono independently instantiates.
- `CLAUDE.md` → "Protected Files" (the fanotify roadmap nono's seccomp-notify supervisor realizes) and "Egress Proxy" (the TLS-passthrough invariant nono's credential proxy would fork).
75 changes: 75 additions & 0 deletions docs/security/nono-roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# nono exploration roadmap

Sequenced plan for exploring [`nolabs-ai/nono`](https://github.com/nolabs-ai/nono) in relation to sandy. Companion to [`nono-comparison.md`](nono-comparison.md) (what nono is + why it's complementary) and [`CREDENTIAL_BROKER_EVALUATION.md`](CREDENTIAL_BROKER_EVALUATION.md) (milestone #12).

**Thesis:** nono and sandy occupy **non-overlapping layers** — sandy is the container/namespace perimeter; nono is in-process capability control + a broker-not-mount credential proxy that nono's own docs say to run *inside* a container. Two independent tracks fall out: **(A) borrow nono's credential/identity *patterns*** (needs no nono dependency), and **(B) validate nono *running inside* sandy** (the precondition for any "better together" story). They can proceed in parallel; the relationship/positioning work is gated on (B).

**Legend:** ⛔ = gate (blocks downstream) · 🧩 = maintainer/Docker-required (I can't run it from inside sandy) · 🤝 = relationship move (via Alec / nolabs-ai) · ⏳ = post-1.x / research horizon.

---

## Phase 0 — ⛔🧩 Validate composition: does nono run *inside* a sandy container?

The whole "better together" claim rests on this, and it is **not** obvious it works — sandy's container is `--read-only`, `--cap-drop ALL`, `--security-opt no-new-privileges`, under Docker's default seccomp profile. nono needs kernel primitives that may or may not survive that.

**Open technical questions to answer:**
1. **seccomp-notify** — nono's supervisor installs a seccomp filter with `SECCOMP_FILTER_FLAG_NEW_LISTENER`. Installing a filter needs *either* `CAP_SYS_ADMIN` *or* `no_new_privs=1`; sandy sets `no_new_privs=1`, so a filter *should* install without caps — **but** obtaining the user-notification listener fd can be gated (kernel/config-dependent, sometimes `CAP_SYS_ADMIN`). Does it work in sandy's cap-dropped container? **This is the single highest-risk unknown.**
2. **Landlock** — needs no privileges (kernel 5.13+), but Docker's default **seccomp profile must allow `landlock_create_ruleset` / `landlock_add_rule` / `landlock_restrict_self`**, and the **host kernel** (the container shares it) must have Landlock enabled. Verify both.
3. **Install path** — nono is a Rust binary; does it install/run on the sandy base image (Debian bookworm, `/home` tmpfs, no root), or does it need baking into the image?

**Deliverable:** a one-page spike report — *runs / doesn't run inside sandy*, and if not, the **exact blocker** plus whether a **narrow** sandy change unblocks it (e.g. a seccomp-profile allowance) **without weakening sandy's posture**. I'll write the test script; a maintainer runs it on a real Docker host.

**Decision gate:**
- ✅ **Composes cleanly (or with a narrow, non-weakening tweak)** → Phase 2 (positioning) unlocks; consider documenting the combined stack.
- ⚠️ **Only composes by weakening sandy** (e.g. re-adding a dangerous cap, dropping no-new-privs) → **do not pursue "nono inside sandy."** Keep the comparison doc, pursue Phase 1 borrows only, and frame the relationship as "adjacent tools," not a stack.
- ❌ **Fundamentally incompatible** → same as ⚠️: Phase 1 only, no stack story.

*→ file as a spike issue.*

---

## Phase 1 — 🧩 Borrow the credential-proxy pattern (independent of Phase 0)

sandy adopts the *pattern*, not the tool — so this needs **no** nono dependency and can run fully in parallel with Phase 0. **Already tracked as issue #121** under milestone #12 (1.7.0).

- **A1 (first):** host-side broker + `cmd://`-style lazy capture — hold the durable credential (OAuth refresh, `gh auth token`) host-side, hand the container only a **phantom / short-lived token**. Record the resolved posture in `sandy-session.json` (mirrors the `effort` field).
- **A2 (gated on A1's residual):** proxy-side injection so the agent never holds even the short-lived token — **requires terminating TLS** (the no-MITM-invariant fork the eval's A2 describes; nono confirms the cost). Do only if A1's residual is judged insufficient.
- **L7 endpoint scoping** (nono's `endpoint_rules`) — closes exfil-to-allowed-host (THREAT_MODEL R3); rides A2.

**Success:** at least one credential (highest-confidence: the Claude/Gemini OAuth refresh→access case A1 already identified) brokered so the raw durable secret never enters the container, provably via the session marker.

---

## Phase 2 — 🤝 Positioning / "better together" (⛔ gated on Phase 0 ✅)

Do **not** advertise a stack that hasn't been validated (Phase 0).

1. **README/docs "composes with nono" note** — factual, low-commitment: *sandy = container/namespace perimeter; for finer-grained per-open capability control + broker-not-mount credentials inside the container, nono composes (and nono's own docs recommend a container/VM perimeter, which sandy provides).* I can draft this PR the moment Phase 0 is ✅.
2. **Mutual cross-recommendation** — a relationship move via Alec / the nolabs-ai team (ex-Sigstore). Make the reciprocal ask **after** Phase 0, so it's backed by "we tested nono-inside-sandy, it works." Keep it a "these compose" note — **no code dependency, no formal partnership** — until there's integration evidence.

---

## Phase 3 — ⏳ Longer-horizon borrows (post-1.x / research)

Real but far past 1.6.0/1.7.0; track, don't schedule.

- **seccomp-notify supervisor ↔ sandy's fanotify `FAN_OPEN_PERM` roadmap** — nono is a working instance of the true-prevention (not detection-only) protected-path enforcement `CLAUDE.md` → "Protected Files" sketches. If Phase 0 shows seccomp-notify works in sandy's container, this becomes concretely reachable.
- **Per-tool child sandboxes** — narrower blast radius than sandy's one-container-runs-everything model.
- **SPIFFE per-instance identity** — composes with the Phase-1 broker (issue #121) *and* the HF-defender externalized-audit-log finding (#2, "which identity made which call").

---

## Sequencing at a glance

```
Phase 0 (validate) ─⛔─► Phase 2 (positioning + outreach via Alec)
└───► Phase 3 (seccomp-notify borrow, iff 0 ✅)

Phase 1 (credential-proxy pattern, issue #121) ──────► independent; parallel with Phase 0
```

**Do-first:** Phase 0 spike (unblocks the whole relationship track) **and** Phase 1 A1 (independent, highest standalone value). **Kill criterion:** if Phase 0 shows nono only runs in sandy by weakening sandy, drop the stack story and keep this to "adjacent, pattern-sharing tools."

## Cross-references
- [`nono-comparison.md`](nono-comparison.md) · [`CREDENTIAL_BROKER_EVALUATION.md`](CREDENTIAL_BROKER_EVALUATION.md) · [`research/credential-broker-cb4a.md`](../../research/credential-broker-cb4a.md)
- Issues: **#121** (credential-proxy pattern, Phase 1) — Phase 0 spike to be filed.
Loading