Skip to content

Add a container primitive built on namespaces and cgroups v2 - #2

Open
erichanwang wants to merge 6 commits into
mainfrom
feat/container-primitive
Open

Add a container primitive built on namespaces and cgroups v2#2
erichanwang wants to merge 6 commits into
mainfrom
feat/container-primitive

Conversation

@erichanwang

Copy link
Copy Markdown
Owner

neuros-container run puts a command in a real cgroup v2 leaf and, when
privilege allows, a fresh mount/UTS/PID/IPC namespace set. No runc,
containerd, or libcontainer underneath; the limits are plain cgroup
files (memory.max, pids.max, cpu.weight) written by hand.

The interesting part was cgroup placement: a cgroup already holding
member processes can't enable subtree_control for children, and an
ordinary interactive shell's own cgroup is exactly such a cgroup. The
tool walks up to the nearest ancestor that already delegates the
wanted controller and nests there instead, which is what lets this run
from a normal terminal without root.

Namespace isolation needs root or an unprivileged user namespace;
Ubuntu 24.04's default AppArmor policy blocks the latter for
unconfined processes, so on a stock install this degrades to
cgroup-only limiting and says so on stderr.

Measured on this machine (see tests/test_container.py):

  • a 16M memory.max cgroup holding a process that touches 200MB of
    bytearray keeps memory.current at the 16M ceiling instead of growing
    past it
  • a pids.max=4 cgroup stops a 20-iteration fork loop after 3 children

Verified: python3 tests/test_nn.py test_autofix.py test_model.py
test_mcp.py test_container.py all green, ./validate-build.sh 249/249.

erichanwang and others added 6 commits July 23, 2026 00:03
neuros-container run puts a command in a real cgroup v2 leaf and, when
privilege allows, a fresh mount/UTS/PID/IPC namespace set, without
runc, containerd, or libcontainer underneath. Resource limits are
plain cgroup files (memory.max, pids.max, cpu.weight) written by hand.

The interesting part turned out to be cgroup placement, not the limit
values: a cgroup already holding member processes can't enable
subtree_control for children (the "no internal process" rule), and an
ordinary interactive shell's own cgroup is exactly such a cgroup. The
tool walks up to the nearest ancestor that already delegates the
wanted controller and nests the new leaf there instead, which is what
lets this run without root from a normal terminal.

Namespace isolation still needs root or an unprivileged user
namespace; Ubuntu 24.04's default AppArmor policy blocks the latter
for unconfined processes, so on a stock install this degrades to
cgroup-only limiting and says so on stderr rather than pretending to
sandbox anything.

tests/test_container.py exercises this for real: a 16M memory.max
cgroup holding a process that touches 200MB of bytearray keeps
memory.current at the 16M ceiling, and a pids.max=4 cgroup stops a
20-iteration fork loop after 3 children.
lb config was missing --mode ubuntu, so live-build defaulted to Debian's
mirrors even with --distribution noble set. Debian has no noble suite, so a
build from a clean checkout failed at bootstrap - a more basic problem than
the missing version pins. Also fixes also-utils -> alsa-utils, which is not a
real package name and would fail regardless.

Nothing was pinned, so "reproducible" was not true: oh-my-zsh tracked master,
GNOME extensions used mutable tags, and the package list had no versions. Pin
oh-my-zsh to a commit, the Ollama installer to the v0.32.5 tag SHA, GNOME
tarballs to commit SHAs, and VS Code plus Continue.dev to explicit versions.
For the ~60 apt packages, point build-time mirrors at a snapshot.ubuntu.com
timestamp rather than pinning each one, which is brittle when the archive
rotates. --mirror-binary deliberately stays on the live archive so installed
systems still receive security updates.

This gives pinned-input reproducibility - same commit, same software versions.
Not bit-identical ISOs: timestamps, file ordering, and initramfs generation
are unaddressed, and the README now says so rather than implying more. Also
still unpinned: ollama pull mistral resolves a floating tag, which would need
a ~4GB pull to verify against a manifest digest.

neuros-mcp spoke HTTP+JSON-RPC only, which no MCP client actually uses. Add a
stdio transport, verified against the official mcp Python SDK - initialize,
list_tools, and call_tool over a real handshake, not an assumed one.

Add scripts/check-airgap.sh, which fails on any external URL outside a
reviewed allowlist, and wire it into CI along with test_mcp.py - both existed
but were never actually run. The build-iso job now runs a real lb config and
resolves the package list against the pinned snapshot instead of only checking
that files exist.
…pecified them

--json was meant to be an alternative single-line output mode (per the
tool's own docstring) but cmd_verify printed the grep-friendly text lines
unconditionally, so --json emitted text followed by JSON and callers
parsing that stream as pure JSON would fail. --quiet was also silencing
the aggregate line, not just the per-file lines, so a quiet run gave no
overall PASS/FAIL at all. Fixed cmd_verify to gate text output on both
flags correctly.

Two of the four failing tests were themselves wrong: test_multi_file_and_merge
asserted the literal substring "verdict=FAIL\n", which the aggregate line's
documented format (verdict=... source=aggregate ...) can never produce since
more fields always follow on the same line -- checked the last line's prefix
instead. test_missing_script_raises assumed setting only NEUROS_POLICY_SCRIPT
to a bad path was enough to force a lookup failure, but the resolver's next
candidate (sibling neuros-policy next to neuros-verify) is a real file in
this checkout, so it always succeeded -- now mocks os.path.isfile so every
candidate misses, matching what the test actually wants to exercise.

Also close the air-gap guard's biggest blind spot: check-airgap.sh only
greps for literal http(s):// URLs, so a shipped tool that imports a
third-party HTTP client (requests/httpx) and builds its target URL at
runtime is completely invisible to it, even though that's the easiest way
to add a real outbound call. Added an import-statement check for those
two libraries; verified zero false positives against the current tree
(no shipped tool uses them, unlike stdlib urllib which is legitimately
used everywhere for local Ollama calls) and that it does catch an
injected `import requests`.
All 13 tests/test_*.py files pass individually and together (383 tests,
0 failures via unittest discover), so CI now runs discover instead of
listing test_nn.py/test_mcp.py by name -- every current and future
test file gates merges without another wiring PR.

check-airgap.sh gaps closed:
- Scans were -maxdepth 1, invisible to anything in a subdirectory of
  the tools path. Now recursive (excluding __pycache__).
- neuros-network's socket.create_connection(("1.1.1.1", 53)) and its
  nslookup subprocess call contain no http(s):// literal, so the
  existing URL scan couldn't see them. Added a raw-socket/DNS-tool
  scan and allowlisted the two known-intentional neuros-network calls
  explicitly (1.1.1.1, google.com) instead of leaving them undetected.
- Investigated removing the neuros-speak:github.com allowlist entry
  (per the stated goal that it only covers a URL printed in a help
  message, never fetched) -- confirmed removing it currently breaks
  the check, since the literal URL scan has no existing skip for
  "URL that only appears in a print statement". Left in place rather
  than silently reintroducing a false positive.

Verified zero false positives across all 86 shipped tools after each
change, and that the new detections still catch injected violations
(raw socket + nslookup to non-allowlisted hosts, in a nested subdir).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016towgcp2NVGxUBbB9Tmpv4
…s, pip

A prior commit (2e4080a) pinned oh-my-zsh, the Ollama installer, GNOME
extension tarballs, VS Code/Continue.dev, and the apt snapshot mirror --
verified all of those still resolve. This closes what a full grep for
curl/wget/git-clone/pip/npm/Dockerfile fetches turned up beyond that:

- Dockerfile: FROM ubuntu:24.04 pinned to its image digest. The bare tag
  is mutable -- Canonical rebuilds it in place for security patches --
  so the Dockerized build container's own starting filesystem was still
  floating. `pip3 install requests` pinned to ==2.34.2.
- .github/workflows/ci.yml: actions/checkout@v4 (x4) and
  docker/setup-buildx-action@v3 pinned to the commit SHA each tag
  currently resolves to, so CI can't silently start running different
  action code after an upstream tag move.

`ollama pull mistral` is still unpinned, documented in README. New
finding: the manifest digest is cheap to look up (registry.ollama.ai's
manifest endpoint returns <1KB, not the 4GB blob) -- the original
assumption that pinning required a full pull was wrong. But Ollama's
pull path has no documented digest-pin syntax the way `docker pull`
does, so there's still no way to verify a pinned reference would work
without the 4GB pull this environment intentionally avoids. Added a
comment at the call site with the exact finding for whoever picks this
up with a real Ollama environment.

config/package-lists/neuros.list.chroot intentionally left alone --
already covered by the snapshot.ubuntu.com pin in build.sh, and pinning
~60 individual package versions on top of that would trade a working
snapshot pin for a build that breaks the moment one exact version
rotates off a mirror.

Verified: bash -n on the edited hook and all other hook.chroot files,
`docker build --check` (confirms Dockerfile syntax and that the pinned
digest resolves against the registry), full validate-build.sh (249/249
pass), and scripts/check-airgap.sh. All new SHAs/versions/digests
confirmed live via GitHub API, PyPI, and `docker buildx imagetools
inspect` before committing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant