37 KiB
nash — the Nucleic Agent Shell
Status: M0 complete (container-side). Vendored brush (brush-shell-v0.4.0)
at third_party/brush/; shell/ workspace builds a working nash (identity
patch: nash --version, NUCLEIC_NASH=1); replay harness at shell/corpus/
reports 94/94 = 100% parity vs bash after fixing fork divergence D1
(brush IFS-split literal words — shell/corpus/DIVERGENCES.md). Brush compat
suite: 1684 succeeded / 6 failed — failure set identical to pristine-brush
baseline (environmental only, zero fork regressions), and the fix repairs 3
upstream known-fail IFS tests (third_party/brush/NUCLEIC_FORK.md). Remaining
M0 tail: grow the corpus from real host-side session transcripts. Next: M1.
nash ("Nucleic agent shell") is a Bourne/bash-compatible shell, forked in Rust, whose job is to make
every shell action an agent takes observable by construction. Where the current shim system
watches two binaries (git, gh) and an opt-in bash DEBUG-trap tracer watches command
metadata, nash sits at the choke point itself: it is the shell, so it sees every simple
command, every pipeline, and — the new capability — the data flowing through redirection
operators and pipes, which it taps and passes through unmodified. It is the logical
end-state of the command-shim system: instead of shimming individual tools ahead of PATH,
Nucleic controls the interpreter that launches all of them.
This doc locks down: the fork base, the crate/repo layout, the interception design (exec
events, redirection taps, pipe tees), the event schema and transport, host-side integration,
how nash becomes the forced default shell on every surface (sandbox containers, the
control/runner container, MCP linux_container, Linux VM, macOS VM), build/distribution,
testing, rollout phases, and risks.
Related docs: VSOCK_CONTROL_PLANE.md (transport this rides on), CONTAINER_ISOLATION.md, LINUX_VM.md, MACOS_VM.md, OBSERVABILITY_AND_TESTING.md (redaction posture), LOCKING.md (consumer of git observations).
Locked decisions
| Decision | Choice |
|---|---|
| Fork base | brush (brush_core::Shell embedded; vendored subtree fork) |
| Role | Hooks now, enforce later — the fork hook is verdict-shaped (Gate), but v1 always allows; enforcement is a later per-surface activation (§4.2) |
| Data-flow retention | Persist to transcript — redirect/pipe events (incl. redacted previews) join the session transcript/GRDB as durable audit history, with retention caps (§9.4) |
| Compat posture | Auto-fallback + telemetry — on parse failure, re-exec preserved real bash and emit a fallback event; agents never break (§4.1) |
| Event delivery | Near-live batching — ~500 ms / 200-event / on-exit flushes over the existing POST pipeline; no persistent streaming channel in v1 (§6.1) |
| Forced surfaces | Sandbox, runner, linux_container, Linux VM; macOS VM last and gated (§7.5). User's real Mac excluded |
| Fork home | In-repo subtree — shell/ workspace + third_party/brush/ subtree in this repo; Rust CI isolated from Swift CI (§3, §8) |
| Update channel | Hybrid — image-baked baseline + host boot-time seed override of a newer binary via the rootfs file-copy path (§8) |
| Capture depth | Redirections, pipes, and command-substitution results; plain un-redirected stdout/stderr stays untapped (transcript already has it) (§5.3) |
| Env mutations | Names only — export/declare events carry variable names, never values (§5.4) |
| Remote sync | Full event sync — the complete shell-event stream (incl. redacted previews) syncs to the iPhone over the E2EE channel, with phone-side retention caps (§9.7) |
| Shim sunset | Coexist + dedupe through M5; locks/autoship flip to nash-driven and shims deleted at M6 after a full clean dogfood cycle (§11) |
| M3 forcing gates | ≥99% corpus parity vs bash, <3% wall-clock overhead, one clean dogfood week (§11) |
| UI surface | Feed rows in the existing Control Panel + click-through detail popover; no dedicated panel in this plan (§9.5) |
1. Goals and non-goals
Goals
- Total command visibility. Every simple command executed by an agent shell — argv,
resolved path, cwd, exit code, duration, pipeline position, nesting (subshell / function /
command substitution) — reported to the host, always on, with no reliance on bash's
BASH_ENV/DEBUG-trap fragility. - Data-flow visibility. Bounded, redaction-aware capture of bytes crossing redirection
operators (
>,>>,<,2>,&>, here-docs/strings,>|, fd dups) and pipes (a | b), passed through byte-for-byte to preserve semantics. - Transparency. Agents and their tools must not behave differently under nash. Existing
scripts,
.bashrc-style init, and the Claude Code / Codex Bash tool must run unchanged. Observation is best-effort and may never block, slow measurably, or alter a command's outcome. On any internal nash failure, the command still runs. - Forced default everywhere Nucleic controls the OS image: sandbox containers, the
cloud runner container, agent-created
linux_containers, the Linux VM, the macOS VM. - Subsume the tracer, converge the shims. The
BASH_ENVcommand tracer (CommandInterceptor.swift) is retired once nash lands; the git/gh shims remain initially (they feed locks/autoship) and are retired once nash-native classification is proven.
Non-goals
- The user's real Mac (host
zshsurfaces:TerminalPanel.swift,BuildRunPanel.swift,host_execvia/bin/zsh -lc,LoginShellPATH.swift). We do not change the operator's machine shell.host_execstays on the user's shell; its guardrail remainsHostExecPolicy.swift. - An interactive daily-driver shell. nash runs non-interactive agent commands; interactive features (highlighting, completion) are stripped from the build.
- A security boundary in v1. nash ships as an observability layer; enforcement stays
where it is today (approval server,
HostExecPolicy, PreToolUse hooks). A determined command can stillexec /usr/bin/dash.real; we record that it did. The hook API is deliberately verdict-shaped so enforcement can be activated later without re-patching the fork (§4.2), but that activation is out of scope for this plan's milestones.
2. Fork base: brush
We do not port the C Almquist/dash lineage to Rust; we fork [brush] (https://github.com/reubeno/brush), a bash/POSIX-compatible shell in Rust:
- MIT-licensed, actively developed (v0.4.0, May 2026), validated against bash with
~1,700 compatibility tests; bash major features (
set -e/-u,pipefail, traps incl.ERR, coprocesses) implemented. - Designed for embedding:
brush_core::Shellis an embeddable engine; the workspace splitsbrush-parser(syntax),brush-core(interpreter:interp.rs,commands.rs,processes.rs,openfiles.rs,sys/),brush-builtins,brush-shell(CLI binary). - Builds static for
x86_64/aarch64Linux (musl) and native macOS — exactly our matrix.
Alternatives considered and rejected:
| Option | Why not |
|---|---|
| Fork dash/busybox-ash (C) | Not Rust; instrumenting C fd plumbing is the bug-prone path this project exists to avoid |
| Shell from scratch in Rust | Years of compat work brush has already done (and tested) |
nsh / rusty_bash / others |
Much lower bash compatibility and maturity than brush |
| Pure wrapper around real bash (pty + strace/eBPF) | No structural knowledge of redirections; eBPF unavailable in our unprivileged container guests; fragile |
Fork mechanics. Vendor the fork as a git subtree at third_party/brush/ (matching the
existing third_party/containerization/ convention), tracking a fork repo
(github.com/abkslm/brush) so upstream merges stay routine. Patches to brush-core are kept
minimal and hook-shaped (see §4.2) to keep the merge surface small; everything else lives in
our own crates.
Naming. nash stands for Nucleic agent shell. Beyond naming the thing for
what it is, the acronym has a useful side effect: it avoids the name of the historical
Almquist shell — ash, still shipped by busybox, with Debian's dash as its descendant —
which a bare "agent shell" contraction would have collided with. No binary named nash
ships in our images or VM rootfs, so there is no on-disk conflict. The binary installs as
/usr/local/bin/nash, nash --version reports nash (Nucleic agent shell, brush fork) x.y.z,
and it sets NUCLEIC_NASH=1 in its own environment so scripts/tests can detect it. Nothing
in the design depends on the name; renaming remains cheap if prior art ever bites.
3. Repo layout
third_party/brush/ # vendored fork (git subtree of abkslm/brush)
shell/ # new Cargo workspace
Cargo.toml
nash/ # bin crate: CLI entry, arg parsing (-c/-lc/-s/files),
# config env, bash-fallback, embeds brush_core::Shell
nash-observe/ # lib crate: event model, taps, batching, spool,
# transport (HTTP over unix socket / TCP), redaction
containers/nucleic-sandbox/ # Dockerfile gains nash install (see §7.1)
.github/workflows/nash.yml # build + publish (see §8)
docs/NASH.md # this doc
shell/ is intentionally outside Sources/ (it's not Swift) and outside guest/
(it ships to containers and VMs). The Cargo workspace pins the vendored brush via
path = "../third_party/brush/brush-core" dependencies.
4. Shell architecture
4.1 The binary
nash is a thin binary embedding brush_core::Shell:
- Accepts the bash-compatible invocation matrix we actually use:
nash -c <cmd>,nash -lc <cmd>,nash <script> [args], stdin scripts,-e/-x/-o pipefailpassthrough. Login (-l) sources/etc/profile+ profile.d so the existingnucleic-path.shPATH setup keeps working. - Reads observation config from env at startup (all optional — absent config means
"run silently as a plain shell"):
NUCLEIC_SHELL_HOOK_URL— event endpoint (http://…/shell-event)NUCLEIC_SHELL_SOCKET— unix socket to speak HTTP over directly (default/run/nucleic/control.sock), preferred over the URL when presentNUCLEIC_HOOK_TOKEN,NUCLEIC_SESSION_ID— existing auth/session varsNUCLEIC_SHELL_SPOOL— spool dir for offline batches (default/var/spool/nucleic-nash)NUCLEIC_SHELL_CAPTURE—off | meta | tap(defaulttap; see §5)NUCLEIC_NASH_DISABLE=1— kill switch: immediatelyexecthe real bash (NUCLEIC_REAL_BASH, default/usr/bin/bash.real, falling back to/bin/bash) with identical argv. This is the "get out of jail" lever for compat emergencies.
- Parse-failure fallback: if brush-parser rejects input that looks like valid bash
(parse error on
-cinput or a sourced file), nash emits afallbackevent and re-execs the real bash with the same argv, so the agent's command still succeeds. This converts brush compat gaps from breakage into telemetry we can fix.
4.2 Patch surface inside brush-core (the fork proper)
Kept deliberately tiny — a Gate trait injected into the shell, with call sites at the
four choke points. Everything else (transport, batching, redaction) is in nash-observe.
The trait is verdict-shaped by design (locked decision: hooks now, enforce later): every
pre-execution hook returns a Verdict, so the same patch supports pure observation today
and policy enforcement later. In v1 the nash-observe implementation returns
Verdict::Allow unconditionally and synchronously — there is no host round-trip on the
command path, and transport stays fire-and-forget. Activating enforcement later is a
per-surface change in nash-observe (a Hold verdict awaiting a synchronous
POST /shell-gate decision, with explicit fail-open/fail-closed timeout semantics), not a
fork re-patch; the endpoint name is reserved but not built in this plan.
// brush-core addition (new file gate.rs, ~1 trait + 2 enums + allow-all default):
pub enum Verdict { Allow(ExecToken), Deny { status: u8, message: String } }
// Hold is modeled as Allow/Deny resolved
// inside the hook; v1 never blocks.
pub trait Gate: Send + Sync {
fn on_exec(&self, ev: ExecStart) -> Verdict; // before spawn
fn on_exit(&self, tok: ExecToken, status: ExecEnd); // after wait
fn on_redirect(&self, ev: RedirectEvent) -> RedirTap; // during redir setup
fn on_pipe(&self, ev: PipeEvent) -> Option<TeeFds>; // pipeline wiring
}
Call sites (file names from brush-core main):
| Choke point | brush-core location | What the hook sees/does |
|---|---|---|
| Simple-command spawn/wait | commands.rs / processes.rs (external cmds), builtin dispatch in interp.rs |
argv, resolved path, cwd, pipeline index, nesting depth; later pid, exit status, duration |
| Redirection setup | interp.rs + openfiles.rs (where redirs become fd mappings) |
operator, target (path/fd/heredoc), open mode; may substitute a tapped fd (§5.2) |
| Pipeline wiring | interp.rs pipe construction |
may interpose a tee pipe (§5.3) |
| Word-level facts | interp.rs (cd, export, source, function def) |
state-change events for the feed |
The Gate default impl is allow-all/no-op, so the vendored fork remains mergeable with
upstream and even upstreamable (a generic hook/gate API is a plausible upstream
contribution, which would shrink our fork to zero).
5. Observation semantics
5.1 Exec events (always, capture ≥ meta)
One event per simple command (external or builtin), including inside functions, subshells,
command substitutions, and sourced files:
argv(post-expansion),resolvedPath,cwd,pid,exitCode,durationMspipeline: {id, index, len}when part ofa | b | cnesting: {depth, kind}(top,subshell,cmdsub,function:NAME,source:PATH)- This strictly supersedes the bash tracer's
{line, cwd, exitCode, durationMs}— richer (structured argv vs. raw line) and reliable (noBASH_ENVinheritance games).
5.2 Redirection taps (capture = tap)
The user-visible requirement: intercept data flowing through redirection operators to observe it, then pass it through as expected. Two mechanisms, chosen per target to guarantee transparency:
(a) Regular-file redirections → open-real-fd + bounded read-back (default).
For > file, >> file, < file, 2> file: nash opens the real file and hands the child
the real fd — semantics (seekability, O_APPEND atomicity, fstat identity, tools that
lseek their output) are untouched by construction. Observation happens around it:
- record
{operator, path, mode, sizeBefore}at setup; - after the command completes, record
sizeAfterand read back the affected range (for>:0..min(size, CAP); for>>:sizeBefore..sizeBefore+CAP; for<: head of the file), attachingbytes,truncated,sha256, and apreview(firstCAPbytes, base64) to the event. CAPdefault 64 KiB per redirection, configurable; binary data detected and previewed as hex head. Special files (/dev/null,/dev/tty, sockets, device nodes) are metadata-only, never read back.
This is chosen over inline tee-ing for files because a tee pipe would silently break any program that seeks on its redirected fd — an unacceptable transparency violation for a default shell.
(b) Stream redirections → inline tee. Where the data is already a stream — pipes
between commands, here-docs/here-strings, process substitution <(…)/>(…), and fd dups
onto pipes — nash interposes: child gets a pipe; an async task (brush-core is tokio-based)
copies bytes through to the real destination while mirroring the first CAP bytes and a
running count/hash into the event buffer. Backpressure is preserved (the tee task copies,
never buffers unboundedly); if the tee task dies, the copy loop degrades to a raw
splice-style passthrough and the event is flagged tapFailed.
5.3 Pipe tees and command substitution
a | b reports one pipe event per link: {pipelineId, fromIndex, toIndex, bytes, truncated, preview, sha256} via mechanism (b). This surfaces what the transcript never
shows today — the intermediate data between stages (curl … | sh becomes visible).
Command substitution is captured; plain stdio is not (locked decision). $(…) and
backtick results are invisible to the agent transcript yet routinely carry decisions —
tokens, branch names, resolved paths — so nash records a cmdsub event with a bounded,
redacted preview of the substituted text (the shell already holds the full result in
memory; no extra tee needed). Un-redirected stdout/stderr of ordinary commands is not
tapped: the agent CLI transcript already records it, and double-capturing would roughly
double event volume for no new signal.
5.4 Caps, redaction, volume
- Per-event preview cap (64 KiB), per-command total capture cap (256 KiB), per-batch cap
(1 MiB); beyond caps → counts/hashes only,
truncated: true. - Redaction runs inside nash before bytes leave the guest: the same posture as
OBSERVABILITY_AND_TESTING.md — pattern-based masking of
obvious credential shapes (bearer/PAT/AWS-style tokens,
PRIVATE KEYblocks) in previews; env vars are never captured wholesale (exec events carry argv, not environment). - Environment mutations are names only (locked decision):
export/declare/unsetproduce events carrying the variable name (export AWS_SECRET_ACCESS_KEY) and never the value — the name alone is the signal that a credential-shaped variable was set, with zero secret-value risk. Host-side classification can flag known-sensitive names in the feed. - Everything is best-effort: full buffers → drop-oldest with a
droppedcounter event.
5.5 Failure doctrine
Observation must never break work. Concretely: no hook may propagate an error into command
execution; transport is fire-and-forget with short timeouts (inherit the shims' 1.5–2 s
budget) and disk spool fallback; panics in nash-observe are caught at the hook boundary;
and the kill switch (NUCLEIC_NASH_DISABLE) plus the preserved real bash mean any
regression has a one-env-var mitigation while we fix forward.
6. Event schema and transport
6.1 Batch shape
Extends the existing /command-event family with a new endpoint POST /shell-event
(bearer-token gated, 202-always like /git-event — see MCPApprovalServer.swift:2868):
{
"type": "shell-batch",
"source": "nash",
"sessionId": "…", // NUCLEIC_SESSION_ID
"shellId": "…", // random per nash process; correlates events
"parentShell": "…|null", // shellId inherited via env → process tree of shells
"events": [
{ "kind": "exec", "seq": 1, "ts": 1721…, "argv": ["git","push"],
"resolvedPath": "/usr/local/bin/git", "cwd": "/workspace", "pid": 412,
"exitCode": 0, "durationMs": 812,
"pipeline": null, "nesting": {"depth": 0, "kind": "top"} },
{ "kind": "redirect", "seq": 2, "cmdSeq": 1, "op": ">>",
"target": {"path": "/workspace/out.log"}, "mode": "append",
"bytes": 1832, "truncated": false, "sha256": "…", "previewB64": "…" },
{ "kind": "pipe", "seq": 3, "pipelineId": "p1", "fromIndex": 0, "toIndex": 1,
"bytes": 90210, "truncated": true, "previewB64": "…", "sha256": "…" },
{ "kind": "cd", "seq": 4, "from": "/workspace", "to": "/workspace/app" },
{ "kind": "cmdsub", "seq": 5, "cmdSeq": 6, "bytes": 41, "truncated": false,
"previewB64": "…" },
{ "kind": "export", "seq": 7, "names": ["AWS_REGION"], "op": "export" },
{ "kind": "fallback", "seq": 8, "reason": "parse-error", "input": "…" }
]
}
(export events carry names only, never values — §5.4.)
Batching (locked decision: near-live batching): flush every 200 events / 500 ms / at
shell exit — matching the tracer's tuning (CommandInterceptor.swift:122-127) so host-side
pacing assumptions hold. This makes the feed feel live for typical commands without a
persistent streaming channel; long-running pipes update once per flush. seq is strictly
ordered per shellId, so a true streaming transport can be swapped in later without
changing the event model.
6.2 Transport per surface
| Surface | Path to host |
|---|---|
Sandbox / runner / linux_container |
Directly HTTP over the vsock-relayed unix socket /run/nucleic/control.sock (NUCLEIC_SHELL_SOCKET). nash is a native binary, so unlike the Node shims it needs no loopback bridge; this removes a Node cold-start per command and works even if control-bridge.js is down. Fallback: NUCLEIC_SHELL_HOOK_URL via the existing 127.0.0.1:9099 bridge. |
| Linux VM | Phase 1: spool to NUCLEIC_SHELL_SPOOL (/var/spool/nucleic-nash/*.jsonl); the existing nucleic-linux-agent gains a tiny shell-events drain op the host polls after each exec. Phase 2 (optional): agent exposes a local socket forwarding to its vsock channel. |
| macOS VM | Same spool-and-drain via NucleicVMAgent (new wire op alongside exec in AgentWire.swift). |
Auth is the existing per-session bearer token (NUCLEIC_HOOK_TOKEN); unknown tokens get
401 and nash stops posting (spools) for that process.
7. Forcing nash as the default shell — per surface
The provisioning inventory below is exhaustive (from a full-repo sweep). "Force" means:
(1) nash is on PATH and is $SHELL; (2) Nucleic's own exec argv uses nash; (3) /bin/sh
and /bin/bash are diverted to nash so even shebangs and tools that hardcode
/bin/sh -c (including the agent CLI's internal Bash tool spawn) land in nash. Real shells
are preserved at .real paths — nash's fallback depends on them.
7.1 Sandbox container image (base for runner and linux_container)
containers/nucleic-sandbox/Dockerfile (base node:22-bookworm-slim, line 20):
# nash: Nucleic agent shell (static musl, per-arch)
COPY --from=nash-dist /nash-${TARGETARCH} /usr/local/bin/nash
RUN chmod 0755 /usr/local/bin/nash \
&& dpkg-divert --divert /usr/bin/bash.real --rename /bin/bash \
&& dpkg-divert --divert /usr/bin/dash.real --rename /bin/dash \
&& ln -sf /usr/local/bin/nash /bin/bash \
&& ln -sf /usr/local/bin/nash /bin/sh \
&& ln -sf /usr/local/bin/nash /bin/dash
ENV SHELL=/usr/local/bin/nash NUCLEIC_REAL_BASH=/usr/bin/bash.real
(Debian merged-usr: /bin/sh symlink handling verified in M0; dpkg-divert keeps apt
upgrades from clobbering the links.)
Version lockstep (must move together, per existing coupling):
containers/nucleic-sandbox/Dockerfilecontent (above).github/workflows/sandbox-image.yml:36—IMAGE_TAG: v7 → v8Sources/NucleicCore/Project.swift:119—defaultImage … :v7 → :v8containers/nucleic-runner/Dockerfile:38— base pinnucleic-sandbox:v7 → :v8
7.2 Swift-side exec paths (sandbox + linux_container)
| Site | Today | Change |
|---|---|---|
ClaudeCodeBackend.swift:2213 (linux_container exec op) |
["/bin/sh","-lc",command] |
["/usr/local/bin/nash","-lc",command] (explicit path; custom images without nash get the §7.6 probe fallback) |
ContainerEngine+Rootfs.swift:184 (seeded /etc/passwd) |
…:/tmp:/bin/bash |
…:/tmp:/usr/local/bin/nash |
CommandInterceptor.hookEnv (CommandInterceptor.swift:61-77) |
git/gh/tracer env | add NUCLEIC_SHELL_SOCKET, NUCLEIC_SHELL_HOOK_URL, reuse token/session; drop BASH_ENV tracer wiring once nash ships |
ContainerEngine.swift:547 (PID-1 keepalive), ContainerEngine+Rootfs.swift:192,202, ContainerManager.swift:598 (seed/probe sh -c) |
sh -c |
unchanged argv — they now resolve to nash via the divert; keep them observation-silent by not passing hook env (plumbing noise, not agent activity) |
The agent CLI itself (Claude Code Bash tool) needs no change: it spawns bash/sh by
path/$SHELL, all of which now resolve to nash. Its BASH_ENV dependence disappears with
the tracer.
7.3 Control / runner container
containers/nucleic-runner/Dockerfile inherits everything from §7.1 via the base-image
bump; nucleicd stays PID 1 (no ENTRYPOINT change). Runner-side execs that shell out pick
up nash through the divert. Cloudflare registry push pipeline unchanged.
7.4 Linux VM
| Site | Today | Change |
|---|---|---|
guest/nucleic-linux-agent/src/agent.c:440 |
execl("/bin/sh","sh","-c",command,…) |
execl("/usr/local/bin/nash","nash","-c",command,…) with fallback to /bin/sh if nash missing (old base images) |
guest/linux-base/provision/provision-linux-guest.sh:147 |
useradd -m -s /bin/bash … |
-s /usr/local/bin/nash; provision step installs the nash binary (fetched like the agent, from the GHCR OCI artifact) and applies the same divert block as §7.1 |
provision-linux-guest.sh:241-245 (profile.d PATH) |
targets /bin/sh execs |
unchanged — nash -l/profile sourcing honors it |
| Agent publish | linux-vm-agents.yml |
gains the drain op (§6.2) and republishes |
Bootstrap-phase scripts (guest/linux-base/bootstrap/init, busybox sh at
scripts/build-linux-bootstrap.sh:46) stay on busybox — they run before provisioning,
never run agent commands, and must stay minimal.
7.5 macOS VM
The one semantically delicate surface: today mac_vm_exec runs /bin/zsh -c
(guest/NucleicVMAgent/…/Ops+Exec.swift:26-27) specifically so /etc/zshenv PATH is
sourced. nash is bash-compatible, not zsh-compatible; user commands occasionally use
zsh-isms, and macOS system shell cannot be diverted (SIP). Plan:
- Build nash as a universal macOS binary; install to
/usr/local/bin/nashinscripts/provision-macos-guest.sh; mirror/etc/zshenv's PATH into/etc/profile.d-equivalent sourcing that nash-lreads. - Switch
Ops+Exec.swiftto/usr/local/bin/nash -lc, keeping a zsh escape hatch in the wire op (shell: "zsh") for compat, and set the agent account's login shell viasysadminctl/dsclduring provisioning. - This is the last phase (M5) and gated on M1–M4 compat data; if zsh-isms show up meaningfully in VM transcripts, the fallback posture is nash-by-default + auto-fallback-to-zsh on parse failure (same doctrine as §4.1).
7.6 Custom images and degradation
linux_container allows custom images (guarded today by a node+bridge probe,
ContainerEngine+Rootfs.swift:200-207). Extend the probe to check for
/usr/local/bin/nash; when absent, seed the static binary the same way shims are seeded
(base64 install won't work for a ~5 MB binary — instead relay it via the existing rootfs
clone/seed file-copy path), or degrade: exec falls back to /bin/sh -lc and the session
records shellObservability: degraded. Never refuse to run.
8. Build and distribution
- Toolchain: Rust stable,
cargoworkspace atshell/. Targets:aarch64-unknown-linux-musl,x86_64-unknown-linux-musl(static, no libc drift across Debian/VM rootfs),aarch64-apple-darwin+x86_64-apple-darwinlipo'd universal. - CI: new
.github/workflows/nash.yml— build matrix, run brush's bash-compat suite against the fork, run nash-observe tests, then publish:- Linux binaries → GHCR OCI artifact
ghcr.io/abkslm/nucleic-nash:<ver>(same single-blob pattern asnucleic-linux-agent,docs/LINUX_VM.md:98-104), consumed by the sandbox Dockerfile (build stagenash-dist) and the Linux VM provisioner. - macOS universal binary → artifact consumed by
provision-macos-guest.sh.
- Linux binaries → GHCR OCI artifact
- Update channel (locked decision: hybrid): the image bakes a known-good nash as the
deterministic baseline; at container/VM boot, the host compares versions and, when it
holds a newer binary (bundled with the app or fetched from the GHCR artifact), seeds it
over the baked one via the existing rootfs file-copy path — the same lifecycle as the
git/gh shim seeding, gated by the
seedRecipeversion. Shell fixes thus reach dogfood in an app update without an image bump; image tags still move at milestone boundaries so the baseline never drifts far. The §7.6 custom-image seeding is this same mechanism. - Version pinning: nash version recorded in the image (label +
nash --version) and re-read after any seed override;SessionControllerlogs the effective version per session so feed events are attributable to a shell build.
9. Host-side integration (Swift)
Follows the existing report pipeline shape exactly (shim → route → struct → backend → coordinator → AppStore → feed):
- Route:
shellEventPath = "/shell-event"inMCPApprovalServer.swift(besidegitEventPath:649); dispatch beside lines 1752-1760;handleShellEventparsesshell-batchinto[ShellReportCall](new struct besideCommandReportCall:570), one per event, carrying the typed payload (exec/redirect/pipe/cd/fallback/dropped). - Registration:
registerShellReport(token:handler:)besideregisterCommandReport(1410-1425); wired in all four backends (ClaudeCodeBackend.swift:2721-2744and Codex/Grok twins). - Coordinator:
ConflictCoordinator.observeShellEvent(sessionID:call:)forwarding toAppStore. - AppStore:
observeShellEvent—execevents flow into the existingobserveCommandpath (AppStore.swift:4666) viaCommandSummary.classify(argv:), taggedsource: .nash, preserving the NVRSION residue-capture backstop (4679-4689) andCMDTRACElogging;execevents whose argv[0] is git/gh also feedobserveGitOp/observeGhOp— this is the convergence path that eventually retires the Node shims. While shims coexist, dedupe on(sessionId, argv, exitCode, ±2 s)preferring the shim event (it's the proven locks/autoship trigger); flip preference once M4 exits.redirect/pipeevents land in a newDataFlowEventvalue type inControlPanel.swift(besideCommandInterceptorEvent:63) and are persisted (locked decision): they join the session transcript store (GRDB) as durable, searchable audit history — redacted previews included — alongside an in-memory capped ring for the live feed. Retention: per-session row cap + a global size budget with oldest-first eviction; both configurable (§9.6).
- UI (locked decision: feed rows + detail popover):
ControlPanelView.swiftactivity feed gains expandable rows for data-flow events — operator glyph, target path, byte count, and preview (monospace, redacted, truncation badge) — with a click-through detail popover showing the full bounded preview, hashes, pipeline structure, and nesting.fallbackevents render as a warning row (these are compat bugs to file upstream). No dedicated shell panel in this plan; the persisted history (§9.4) leaves that open as a future milestone. - Settings:
nucleic.container.commandTracing(Project.swift:572-576) is superseded bynucleic.container.shellCapture=off | meta | tap(defaulttap), mapped toNUCLEIC_SHELL_CAPTURE.nucleic.container.shellRetentionbounds the persisted data-flow history (per-session rows + global size budget, §9.4). A separate rollback levernucleic.container.legacyShell(default false) injectsNUCLEIC_NASH_DISABLE=1and restores the tracer wiring — one toggle to fully revert behavior without an image rollback. - iPhone sync (locked decision: full event sync): shell events enter the normalized
AgentEvent/HostMsgstream (SYNC_PROTOCOL.md) as their own event family with the canonicalseqcursor, so catch-up after disconnect works unchanged. The complete stream syncs — exec, data-flow, cmdsub, export-name, and fallback events including their redacted, capped previews — over the existing E2EESecureChannel; previews never travel un-redacted because redaction already happened in-guest (§5.4). Two accommodations for the thin client: (a) data-flow payload frames are marked low-priority so approval traffic always preempts them on LAN/relay; (b) the phone keeps a bounded local ring (per-session row + size caps mirroring §9.4) with oldest-first eviction — full stream, bounded retention. Relay-mode bandwidth is the main cost; see risk table.
10. Testing
- Fork conformance (CI-gating): brush's ~1,700-case bash compat suite must pass on
the fork with observation on — proving hooks don't perturb semantics. Run twice
(
capture=off,capture=tap) and diff outcomes. - Tap semantics (Rust integration tests,
nash-observe): append preserves interleaving under concurrent writers; seek-after-redirect (dd of=…) byte-identical to bash;set -o pipefailexit codes through tees; SIGPIPE propagation through tees (yes | head); binary data through pipes uncorrupted (hash comparison);/dev/null, fifos,2>&1dup chains; heredoc capture; cmdsub preview matches$(…)result byte-for-byte under cap; export events never contain values; 100 MB stream under cap → correct byte count, bounded memory. - Transcript replay corpus: extract the Bash-tool command corpus from existing session transcripts (fixtures pattern per OBSERVABILITY_AND_TESTING.md) and replay under nash vs bash in the sandbox image, diffing stdout/stderr/exit. This is the M0 gate and a permanent regression suite.
- Swift tests:
ShellReportParsingTests(batch → calls) besideMCPApprovalServerTests;ContainerSandboxTestsextension asserting the v8 image resolves/bin/sh → nashand that alinux_containerexec produces exec + pipe events end-to-end; dedupe tests for the git-shim coexistence window. - Fault injection: control socket absent (spool fills, commands unaffected); host 401
(posting stops, commands unaffected);
nash-observepanic (hook boundary catches; command completes).
11. Rollout phases
| Phase | Deliverable | Exit criteria |
|---|---|---|
| M0 — spike | Fork subtree; nash -c runs via embedded brush; transcript-replay corpus harness |
≥99% corpus parity vs bash; list of divergences filed |
| M1 — exec events | Gate trait patch (allow-all); exec/cd/fallback events; unix-socket transport; /shell-event route → feed; SHELL+linux_container argv switched (no divert yet); tracer retired |
Feed shows nash events for a live session; tracer deleted; compat suite green |
| M2 — data-flow taps | Redirect read-back + pipe/heredoc tees; caps + redaction; UI preview rows | Tap test matrix green; overhead <3% on corpus wall-clock |
| M3 — force sandbox/runner | v8 image with divert; lockstep bumps; custom-image probe/degrade; legacyShell rollback lever |
Week of dogfood sessions, zero fallback-event regressions unresolved |
| M4 — Linux VM | agent.c exec swap + provisioner install + spool drain | linux_vm_exec events in feed; VM compat parity |
| M5 — macOS VM (gated) | universal binary, Ops+Exec swap with zsh escape hatch |
zsh-ism fallback rate ≈0 in dogfood, else hold |
| M6 — converge | git/gh shim retirement behind nash-native classification; upstream the Gate trait | locks/autoship driven by nash events for a full dogfood cycle |
Post-M6 (explicitly out of scope here, enabled by the locked hook design): activating
enforcement — Hold/Deny verdicts backed by a synchronous /shell-gate policy
round-trip, per surface, with its own design doc.
12. Risks and mitigations
| Risk | Mitigation |
|---|---|
| brush compat gaps break agent commands | Parse-fallback to preserved real bash + fallback telemetry; kill switch env; legacyShell setting; M0 corpus gate before any forcing |
| Tee alters semantics for seek-dependent tools | Design rule: regular files never tee'd (real fd + read-back); tees only where a pipe already exists |
| Performance (async tees, event posting) | Static musl binary (no Node cold start — strictly faster than today's shims); fire-and-forget posts; <3% overhead gate in M2 |
| Data volume / secrets in captured bytes | Caps at three levels; in-guest redaction before transport; previews only, never full payloads; capture level setting incl. meta-only |
| Upstream drift of brush fork | Subtree + minimal hook-shaped patch; aim to upstream the Gate trait (fork → zero) |
| Persisted previews put (redacted) command data at rest | Redaction runs in-guest before transport (§5.4); previews only, hard caps; transcript retention caps + shellCapture level can drop payloads entirely; transcript store already holds session data under the same protections |
| Full iPhone sync strains LAN/relay bandwidth and phone storage | Previews are capped/redacted before they ever reach the sync layer; data-flow frames are low-priority behind approvals; phone-side ring with retention caps; relay verbosity throttling remains the backstop if measured cost is too high |
nash name prior art |
The acronym ("Nucleic agent shell") both describes the tool and avoids the Almquist ash collision; no nash binary ships in our images; version string + NUCLEIC_NASH=1 disambiguate; rename stays cheap |
| Image/tag lockstep mistakes | The four-place v8 bump listed in §7.1 is a single PR checklist; CI asserts Dockerfile tag == Project.swift tag |
Agent bypasses nash (exec /usr/bin/bash.real) |
Accepted (non-goal §1); the exec event recording the bypass is itself the observability win; .real paths can be policy-flagged in feed classification |
| macOS zsh-isms | M5 gated; per-op shell: escape hatch; auto-fallback to zsh on parse failure |