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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ On a release, the maintainer moves the accumulated `[Unreleased]` entries into a

## [Unreleased]

- **feat(egress-hard): OPT-IN hard egress boundary for the container sandbox backend (`NILCORE_EGRESS_HARD`).** The container egress allowlist is COOPERATIVE by default — a model command that ignores `HTTP(S)_PROXY` (`curl --noproxy`, raw sockets, `/dev/tcp`) reaches any host. Hard mode makes it UNBYPASSABLE: `applyContainerEgress` (under the opt-in) routes the box through the new `Container.AllowEgressViaHard`, which attaches the sandbox to a per-run `--internal` docker/podman network (no default route) and runs the allowlist proxy as a dual-homed GATEWAY container (a new hidden `nilcore egress-gateway` verb) the sandbox reaches by IP; a raw socket / `--noproxy` then has no route out and fails, while the sandbox keeps `--cap-drop=ALL`. Fail-closed on ANY setup error (stays `--network none`, never cooperative-fallback); the sandbox `--dns` is pinned at the gateway to blackhole in-sandbox DNS; an idempotent teardown + a label-scoped boot reaper (`reapHardEgress`, non-blocking, gated on the opt-in) reclaim orphans. Honest residuals documented (DNS-tunnel only mitigated; requires the nilcore image, not `debian:stable-slim`; Linux-container only, CI-validated). The namespace backend's empty netns remains the RECOMMENDED hard boundary; `NILCORE_EGRESS_STRICT` stays the fail-closed cooperative refusal. Default (opt-out) path is byte-identical. Unit-tested: the sandbox arg assembly (`AllowEgressViaHard`/`--dns`), the STRICT/hard decision seam (fake `setupHardEgressFn`), and the `egress-gateway` proxy logic; the container lifecycle is CI-only. _Owns:_ `internal/sandbox`, `cmd/nilcore/egress_gateway.go`, `cmd/nilcore/egress_hard.go`, `cmd/nilcore/chat.go` (applyContainerEgress), `cmd/nilcore/main.go` (dispatch + serve reaper), `docs/ARCHITECTURE.md`. _(Phase 7 / deferred hardening)_

- **chore(audit-2026-07-10-remediation): fresh 12-agent adversarial audit → fix every CRITICAL/HIGH/MEDIUM finding + the cheap LOWs; all three gates green.** A fresh adversarial audit (10 commissioned auditors + independent per-finding verification) at 63292d7 surfaced **1 CRITICAL, ~11 HIGH, ~12 MEDIUM** (plus LOW/hygiene), every one personally verified; all are fixed here with a discriminating test, and `make verify` + `make test-race` (0 data races) + `make tui-verify` are green. **CRITICAL:** the browser/desktop `{{secret:NAME}}` type action resolved ANY process env var (a confused-deputy exfil primitive — a hostile/injected page could type `{{secret:ANTHROPIC_API_KEY}}` into a form and submit it to an egress-allowed sink); it now honors an operator-declared per-task secret-name allowlist (`-secrets` / `NILCORE_{BROWSE,DESKTOP}_SECRETS`, default empty = deny-all) and capguard axis-B counts a secret-capable session. **HIGH:** the host-side git tool can no longer be tricked into repo-local-config RCE (writes inside `.git` are refused at the `worktreefs.writeNoFollow` chokepoint + the git tool passes per-command `--no-ext-diff`); container egress is documented as a cooperative proxy (not a hard boundary) with a `NILCORE_EGRESS_STRICT` fail-closed opt-in; vcache no longer replays a stale GREEN past a changed evidence artifact (the `.nilcore/artifacts` digest is folded into the key under evidence-verify); a value-bearing artifact claim can no longer ship a hollow green on a value-blind verifier (url_resolves/not_stale/variance_bounded); `sleep`/suspend preserves committed work on a durable `suspend/<id>` branch instead of `git branch -D`ing it; race-recovered verified work keeps its branch under KeepBranch; the desktop per-action gate is armed on `--mac-host` (every mutation gated); the webhook self-start is rate/label-bounded (denial-of-wallet); `nilcore chat`/`tui` resolve `-backend auto`; the circuit breaker is no longer poisoned by user steers/cancels. **MEDIUM:** the promote gate scopes on the target base (not the source tip); MCP responses are size-bounded (host OOM); `VAR=0` now DISABLES (not enables) NILCORE_{AUTONOMY,FLYWHEEL,LIVE_INDEX,LESSONS}; codeintel indexers read with O_NOFOLLOW + a size cap; the run/chat/serve advisor is metered against the budget wall; `swarm -blast-radius` is wired; the memory table is pruned/bounded; the webhook shares serve's one store handle; a cancelled drive unwinds cleanly (no false "Run errored"); a cleanly-errored task isn't re-executed on serve boot; the converged `nilcore build` deliverable is pinned before cleanup. **LOW:** eventlog redaction covers `[]string`/`json.RawMessage`; Slack WS frames are length-capped; the Telegram token can't ride a URL error. Integration-seam fix caught by the assembled-diff gate: a defense-in-depth `-c diff.external=` (empty) made git exec the empty string and broke every `git diff` — removed (the `.git` write-guard is the real defense). _Owns:_ repo-wide (see the diff). _(remediation)_

- **chore(license-apache-2.0)** — Add the Apache License 2.0, an RNT56/NilCore NOTICE attribution, and README license badge/section. _Owns:_ `LICENSE`, `NOTICE`, `README.md`, `CHANGELOG.md`. _(docs)_
Expand Down
50 changes: 48 additions & 2 deletions cmd/nilcore/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ func chatMain(args []string) {
egress, proxyAddr, stopProxy := startEgress(ctx, allow, console, proxyBindAddr(*cf.common.sandboxPref, *cf.common.runtime))
defer stopProxy()

// Rule of Two (§2): evaluate the lethal trifecta ONCE at startup — untrusted web input
// (A) ∧ private repo data (B, the mounted worktree) ∧ open egress (C). The shipped
// default egress is deny-all, so the verdict is Allow and this is byte-identical; only
// a wide/wildcard egress allowlist trips it. chat is attended, so a trip prompts once at
// the console. NILCORE_RULE_OF_TWO=0 opts out.
if err := enforceRuleOfTwo(log, ruleOfTwoEnforced(), !egress.Empty(), true, egress,
policy.NewConsoleApprover(os.Stdin, os.Stdout),
"start a coding session combining untrusted web input, private repo data, and open egress"); err != nil {
fatal(err)
}

sess, err := buildChatSession(chatDeps{
flags: cf,
provider: prov,
Expand Down Expand Up @@ -476,9 +487,12 @@ func splitHosts(s string) []string {
// for docker, with an --add-host so it resolves on docker-Linux too).
// egressWarnOnce/egressStrictWarnOnce bound the container-egress security advisories
// to one line per process (applyContainerEgress runs per drive / per swarm worker).
// egressHardWarnOnce/egressHardFailWarnOnce do the same for the HARD-mode advisories.
var (
egressWarnOnce sync.Once
egressStrictWarnOnce sync.Once
egressWarnOnce sync.Once
egressStrictWarnOnce sync.Once
egressHardWarnOnce sync.Once
egressHardFailWarnOnce sync.Once
)

func applyContainerEgress(box sandbox.Sandbox, egress policy.Egress, proxyAddr, runtime string) {
Expand All @@ -489,6 +503,16 @@ func applyContainerEgress(box sandbox.Sandbox, egress policy.Egress, proxyAddr,
if !ok {
return
}
// HARD egress (opt-in NILCORE_EGRESS_HARD): make the allowlist UNBYPASSABLE via a
// --internal network + a dual-homed gateway container, instead of the cooperative
// bridge+proxy below. On ANY setup failure this FAILS CLOSED — the box stays
// --network none (deny-all) — and NEVER silently falls back to cooperative bridge.
// Linux-container only, CI-validated (see egress_hard.go). Default (unset) ⇒ the
// existing STRICT/cooperative paths below run byte-identically.
if envOptIn("NILCORE_EGRESS_HARD") {
applyHardEgress(c, egress, runtime)
return
}
// The container backend's egress allowlist is enforced by a COOPERATIVE proxy over a
// bridged network — a model-emitted command that ignores HTTP(S)_PROXY (curl
// --noproxy, raw sockets, /dev/tcp) can still reach arbitrary hosts, including cloud
Expand Down Expand Up @@ -524,6 +548,28 @@ func applyContainerEgress(box sandbox.Sandbox, egress policy.Egress, proxyAddr,
c.AllowEgressVia(policy.ProxyURL(net.JoinHostPort(hostAlias, port)))
}

// applyHardEgress wires the container to a HARD egress boundary (opt-in
// NILCORE_EGRESS_HARD): the allowlist proxy runs as a dual-homed gateway on a
// --internal network with no route out, so it is UNBYPASSABLE (see egress_hard.go).
// It reuses ONE gateway per (runtime,image,allowlist) across drives. On ANY setup
// failure it FAILS CLOSED — the box keeps --network none (deny-all) — and never falls
// back to the cooperative bridge. Callers pass the container box (already cast).
func applyHardEgress(c *sandbox.Container, egress policy.Egress, runtime string) {
egressHardWarnOnce.Do(func() {
fmt.Fprintln(os.Stderr, "nilcore: NILCORE_EGRESS_HARD set — routing container egress through a --internal-network gateway (the allowlist becomes unbypassable). Linux-container only; requires the nilcore image (a debian:stable-slim has no nilcore binary); the DNS-tunnel residual is only mitigated. The namespace backend (Linux) remains the recommended hard boundary.")
})
h, ok := getHardEgress(runtime, c.Image, egress)
if !ok {
// FAIL CLOSED: leave the box at --network none. Never cooperative-fallback.
egressHardFailWarnOnce.Do(func() {
fmt.Fprintln(os.Stderr, "nilcore: NILCORE_EGRESS_HARD — hard egress setup FAILED; egress stays deny-all (--network none). Ensure a Linux container runtime + the nilcore image, or unset NILCORE_EGRESS_HARD to accept cooperative egress.")
})
return
}
c.AllowEgressViaHard(h.network, h.proxyURL)
c.DNS = h.dns
}

// containsString reports whether s is present in xs (small linear scan — the slices
// it guards, like a box's ExtraHosts, hold only a handful of entries).
func containsString(xs []string, s string) bool {
Expand Down
101 changes: 101 additions & 0 deletions cmd/nilcore/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,107 @@ func TestApplyContainerEgress(t *testing.T) {
})
}

// TestApplyContainerEgressHard proves the HARD-mode DECISION seam in
// applyContainerEgress without a real container: with NILCORE_EGRESS_HARD set and a
// FAKE setupHardEgressFn, an ok setup routes the box through the internal-net gateway
// (AllowEgressViaHard + --dns), while a failing setup FAILS CLOSED (the box stays
// --network none, no proxy env) — it must NEVER fall back to the cooperative bridge.
func TestApplyContainerEgressHard(t *testing.T) {
egress := policy.Egress{Allowed: []string{"example.com"}}

// Isolate package-level hard-egress state so cache/teardown carry-over between
// subtests (and other tests) can't taint these assertions.
reset := func() {
hardMu.Lock()
hardTeardowns = nil
hardCache = map[string]hardEgressHandle{}
hardMu.Unlock()
}
restore := setupHardEgressFn
t.Cleanup(func() { setupHardEgressFn = restore; reset() })

t.Run("setup ok routes through the internal-net gateway", func(t *testing.T) {
reset()
t.Setenv("NILCORE_EGRESS_HARD", "1")
var teardownCalls int
setupHardEgressFn = func(runtime string, e policy.Egress, image string) (string, string, func(), error) {
return "nilcore-egr-net-fake", "http://10.42.0.5:3128", func() { teardownCalls++ }, nil
}
box := sandbox.NewContainer("podman", "img", "/work")
applyContainerEgress(box, egress, "0.0.0.0:54321", "podman")

if box.Network != "nilcore-egr-net-fake" {
t.Errorf("hard ok: Network = %q, want the internal net", box.Network)
}
if box.Network == "bridge" {
t.Errorf("hard mode must NEVER use the cooperative bridge")
}
if box.Env["HTTP_PROXY"] != "http://10.42.0.5:3128" {
t.Errorf("hard ok: HTTP_PROXY = %q, want the gateway", box.Env["HTTP_PROXY"])
}
if box.DNS != "10.42.0.5" {
t.Errorf("hard ok: DNS = %q, want the gateway IP (blackhole in-sandbox DNS)", box.DNS)
}
if len(box.ExtraHosts) != 0 {
t.Errorf("hard mode must add no --add-host, got %v", box.ExtraHosts)
}
// Reused across drives: a second apply must not spawn a second gateway.
before := teardownCalls
box2 := sandbox.NewContainer("podman", "img", "/work")
applyContainerEgress(box2, egress, "0.0.0.0:54321", "podman")
hardMu.Lock()
n := len(hardTeardowns)
hardMu.Unlock()
if n != 1 {
t.Errorf("hard gateway should be reused per (runtime,image,allowlist); teardowns=%d, want 1", n)
}
if teardownCalls != before {
t.Errorf("reuse must not tear down the shared gateway")
}
// A clean drain fires the single registered teardown exactly once.
stopHardEgress()
if teardownCalls != 1 {
t.Errorf("stopHardEgress should fire the gateway teardown once, got %d", teardownCalls)
}
})

t.Run("setup failure fails closed (never bridge)", func(t *testing.T) {
reset()
t.Setenv("NILCORE_EGRESS_HARD", "1")
setupHardEgressFn = func(string, policy.Egress, string) (string, string, func(), error) {
return "", "", nil, errStub
}
box := sandbox.NewContainer("podman", "img", "/work")
applyContainerEgress(box, egress, "0.0.0.0:54321", "podman")

if box.Network != "none" {
t.Errorf("hard setup failure must FAIL CLOSED at --network none, got %q", box.Network)
}
for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY"} {
if box.Env[k] != "" {
t.Errorf("failed hard setup must set no proxy env, got %s=%q", k, box.Env[k])
}
}
})

t.Run("default (opt-out) stays cooperative", func(t *testing.T) {
reset()
// NILCORE_EGRESS_HARD unset ⇒ the fake must never be consulted; cooperative wiring.
setupHardEgressFn = func(string, policy.Egress, string) (string, string, func(), error) {
t.Fatal("hard setup must NOT run when NILCORE_EGRESS_HARD is unset")
return "", "", nil, nil
}
box := sandbox.NewContainer("podman", "img", "/work")
applyContainerEgress(box, egress, "0.0.0.0:54321", "podman")
if box.Network != "bridge" {
t.Errorf("default path should stay cooperative (bridge), got %q", box.Network)
}
})
}

// errStub is a sentinel error for the hard-egress failure path.
var errStub = errors.New("stub setup failure")

func TestWebEnabled(t *testing.T) {
off := chatDeps{}
if off.webEnabled() {
Expand Down
63 changes: 63 additions & 0 deletions cmd/nilcore/egress_gateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// egress_gateway.go wires the hidden `egress-gateway` verb: the process the HARD
// egress GATEWAY container runs (docs/ARCHITECTURE.md §Execution-model/egress). It is
// deliberately NOT advertised in `nilcore help` — it is machinery the hard-egress
// lifecycle (egress_hard.go) launches inside a dual-homed container, not an operator
// command. It binds a policy.EgressProxy (the SAME allowlist + SSRF guard the
// cooperative proxy uses) on -listen and serves it until the process is signalled.
//
// In hard mode the sandbox is attached to a `--internal` network with no route out;
// its only path off-box is this gateway (dual-homed: internal net + a normal net),
// so the allowlist becomes UNBYPASSABLE rather than merely cooperative. See
// sandbox.Container.AllowEgressViaHard and setupHardEgress.
package main

import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"

"nilcore/internal/policy"
)

// egressGatewayMain parses -allow/-listen and serves the allowlist proxy until the
// process receives SIGINT/SIGTERM (the container stop signal). It is the entrypoint
// the gateway container invokes as `nilcore egress-gateway -allow <hosts> -listen …`.
func egressGatewayMain(args []string) {
fs := flag.NewFlagSet("egress-gateway", flag.ExitOnError)
allow := fs.String("allow", "", "comma-separated allowlist of hosts the sandbox may reach through this gateway")
listen := fs.String("listen", "0.0.0.0:3128", "address:port to bind the allowlist proxy on")
_ = fs.Parse(args)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := runEgressGateway(ctx, splitHosts(*allow), *listen); err != nil {
fatal(err)
}
}

// startEgressGateway binds the allowlist proxy on listen and serves it in the
// background, returning the bound address and an idempotent stop func. Extracted as
// the testable seam of the verb: a test can bind 127.0.0.1:0, exercise the running
// proxy (deny→403, allow→past-the-allowlist), then stop it — without a process.
func startEgressGateway(ctx context.Context, allow []string, listen string) (addr string, stop func(), err error) {
proxy := &policy.EgressProxy{Egress: policy.Egress{Allowed: allow}}
return proxy.Start(ctx, listen)
}

// runEgressGateway starts the gateway and blocks until ctx is cancelled (the verb's
// long-running body). The allowlist + SSRF guard in policy.EgressProxy gate every
// request regardless of bind interface.
func runEgressGateway(ctx context.Context, allow []string, listen string) error {
addr, stop, err := startEgressGateway(ctx, allow, listen)
if err != nil {
return fmt.Errorf("egress-gateway: %w", err)
}
defer stop()
fmt.Fprintf(os.Stderr, "nilcore egress-gateway: allowlist proxy on %s (%d allowed host(s))\n", addr, len(allow))
<-ctx.Done()
return nil
}
82 changes: 82 additions & 0 deletions cmd/nilcore/egress_gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"context"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)

// TestEgressGatewayServesAllowlist proves the hidden egress-gateway verb's testable
// core: it binds the allowlist proxy on the given address, refuses a denied host
// with a 403 before any dial, lets an allowlisted host PAST the allowlist (the -allow
// entry took effect — a denied host would 403, this one reaches the SSRF guard), and
// shuts down cleanly on ctx cancel. Hermetic: no host is ever dialed off-box (the
// allowed host is a loopback literal the SSRF guard blocks fast).
func TestEgressGatewayServesAllowlist(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())

// Allow a loopback literal: the allowlist passes it, but the proxy's SSRF guard
// then refuses loopback — a fast, network-free failure that is DISTINCT from the
// "egress denied" 403, so it proves the -allow entry was applied.
addr, stop, err := startEgressGateway(ctx, []string{"127.0.0.1"}, "127.0.0.1:0")
if err != nil {
t.Fatalf("start gateway: %v", err)
}

proxyURL, _ := url.Parse("http://" + addr)
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}

// Denied host ⇒ 403 with an "egress denied" body (rejected before any dial/DNS).
resp, err := client.Get("http://denied.example/")
if err != nil {
t.Fatalf("denied request errored at transport: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("denied host status = %d, want 403", resp.StatusCode)
}
if !strings.Contains(string(body), "egress denied") {
t.Errorf("denied host body = %q, want an 'egress denied' message", string(body))
}

// Allowed host ⇒ PAST the allowlist. It is not the 403 "egress denied" refusal;
// the SSRF guard then blocks loopback with a 502, proving the allowlist let it in.
resp2, err := client.Get("http://127.0.0.1:9/")
if err != nil {
t.Fatalf("allowed request errored at transport: %v", err)
}
body2, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
if strings.Contains(string(body2), "egress denied") {
t.Errorf("allowlisted host was refused by the allowlist: %q", string(body2))
}
if resp2.StatusCode == http.StatusForbidden && !strings.Contains(string(body2), "private/local") {
t.Errorf("allowlisted host got an unexpected 403: %q", string(body2))
}

// Shutdown: cancel ctx (the container-stop analog) ⇒ the listener closes and a
// fresh proxied request can no longer be served.
cancel()
stop()
deadline := time.Now().Add(2 * time.Second)
down := false
for time.Now().Before(deadline) {
c := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: 200 * time.Millisecond}
if _, err := c.Get("http://denied.example/"); err != nil {
down = true
break
}
time.Sleep(20 * time.Millisecond)
}
if !down {
t.Error("gateway still serving after ctx cancel + stop; want the listener closed")
}
}
Loading
Loading