diff --git a/docs/security/CREDENTIAL_BROKER_EVALUATION.md b/docs/security/CREDENTIAL_BROKER_EVALUATION.md index f950e41..ee977e9 100644 --- a/docs/security/CREDENTIAL_BROKER_EVALUATION.md +++ b/docs/security/CREDENTIAL_BROKER_EVALUATION.md @@ -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). diff --git a/docs/security/nono-comparison.md b/docs/security/nono-comparison.md new file mode 100644 index 0000000..fc52001 --- /dev/null +++ b/docs/security/nono-comparison.md @@ -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:/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`, 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). diff --git a/docs/security/nono-roadmap.md b/docs/security/nono-roadmap.md new file mode 100644 index 0000000..431e5d6 --- /dev/null +++ b/docs/security/nono-roadmap.md @@ -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.