Files
nucleic/docs/NASH.md
T

94 KiB
Raw Blame History

nash — the Nucleic Agent Shell

Status: M0M2 complete; M3's image + host work is implemented via narOS N2 (NAROS.md) — its gates are pending. M3 state: the divert is baked (naros-base, since narOS N1), naros-agent is built as the N2 image layer, and the §7.2 Swift swap is in — Project.swift pin → naros-agent:26.07, exec argv/passwd prefer nash, the §7.6 probe gained the ID=naros fast path, and the legacyShell rollback lever is live (ContainerServiceSettings.legacyShell → execs use the preserved bash.real and the seeded policy file carries disable=1 — §4.3). Still open before the forcing is shipped: the M3 gate set — ≥99% corpus parity replayed in-image, <3% overhead, one clean dogfood week, zero unresolved fallback regressions. Runner surface (§7.3) implemented via narOS N3: nucleic-runner rebases FROM naros-agent (nash forced by inheritance), and — because runner sessions are host runs (RunSpec.container = nil, no shim seeding) — ClaudeCodeBackend now detects a narOS host (CommandInterceptor.hostIsNarOS, /etc/os-release ID=naros), registers the git/gh/command/shell report routes for those runs, and merges hookEnv (tracer off, legacyShell lever honored) into the host spawn env pointed at the loopback approval server — so nash events flow to the feed, and git/gh observation on the runner arrives via observeShellEvent's argv convergence rather than shims. Codex/Grok host runs still start no approval server; wiring them is a follow-up. M4's Linux-VM spool-drain (§6.2, §7.4) is implemented; N4-baked nash in the guest is the remaining dependency. A VM-resident nash can't reach the host control socket, so it spools batches to /var/spool/nucleic-nash/*.jsonl; nucleic-linux-agent gained a non-streaming shell-events op (agent.c) that reads those files, returns them base64 as NDJSON, and truncates what it delivered — at-least-once (files are unlinked only after a successful send). The agent also setenvs NUCLEIC_SHELL_SPOOL + NUCLEIC_SHELL_CAPTURE=tap in its own environment so every exec'd shell (nash under the N4 /bin/sh→nash divert) spools by inheritance — inert on the current Ubuntu guest where the shell isn't nash. Host side: MacVMAgentWire.ShellEvents + MacVMEngine.drainShellEvents decode the reply (NDJSON split skips any mid-write partial line), and ClaudeCodeBackend.handleLinuxVMExecCall polls the drain after every manager.run, feeding each batch through the existing parseShellBatchobserveShellEvent seam — the same pipeline as the container surface, with the backend's session id authoritative (as in handleShellReport). No agent.c exec swap and no provisioner changes (nash forcing is narOS N4's baked divert). Verified: guest loopback tests (ping/exec unchanged, plus drain of two spool files, truncate-on-drain, non-.jsonl skip, and missing-dir → empty), and Swift ShellReportParsingTests 7/7 incl. base64-NDJSON split with a partial-trailing-line skip. Live linux_vm_exec feed events await narOS N4 baking nash into the guest rootfs. M5's macOS-VM path (§7.5) is implemented and FORCED: nash is the only shell the exec surface runs (locked, 2026-07-23) — no zsh fallback, no per-request override. The macOS guest agent (NucleicVMAgent) gained the same shell-events drain op — VMAgentCore.ShellSpool reads ~/Library/Caches/nucleic-nash/*.jsonl (per-user, since the agent runs as agent, not root), the Ops+ShellDrain handler returns them base64/NDJSON and truncates after a successful send (at-least-once), and AgentListener routes it alongside exec. Ops+Exec runs every command under /usr/local/bin/nash -lc; a guest without nash refuses the exec with an actionable spawnError (rebuild the base) rather than degrading to zsh, and provisioning correspondingly hard-fails when no nash binary is staged. The only exception is the in-guest operator break-glass (§4.1) — disable=1 in the root-owned policy file (§4.3), not wire and no longer environment; the wire's former shell:"zsh" escape hatch is removed. Incident note (2026-07-22/23): a mac_vm_exec "never returns" hang was briefly mis-attributed to darwin nash. The real cause was an invisible per-user kTCCServiceSystemPolicyNetworkVolumes consent prompt against the agent bundle — the virtiofs workspace share counts as a network volume, so the first share-touching child blocked until the 30-minute cap (proven via tccd logs; clicking Allow un-wedged a live clone instantly). nash was not implicated. Fixed in the base per SIP posture (macOSProvisioningRecipe v6): SIP off → a fourth TCC row in the agent user's TCC.db (provision-macos-guest.sh Phase 7d); SIP on (the supported macOS 27 base, where no db write exists) → the engine boots the base once and answers the real consent — Vision finds the alert's exact-match "Allow", the host-side virtual HID clicks it, and a bounded agent-attributed share touch verifies (MacVMEngine+ConsentGrant); v5 bases take that as a light one-boot pass, not a full re-provision. The boot-time host-path exec is also bounded now, so an ungranted clone fails fast with an error naming this consent instead of wedging the session. Host side: the M4 drain helper generalized to drainVMShellEvents and is now polled after mac_vm_exec too (the drain wire is guest-OS-agnostic, so it needed no new host protocol code). Provisioning (provision-macos-guest.sh) installs the staged nash (required), mirrors the toolchain PATH into /etc/profile.d for nash -l, and sets the agent login shell. Verified: the NucleicVMAgent package builds and VMAgentCoreTests pass incl. the drain (concatenate + non-.jsonl skip, missing-dir → empty, oversize-file-left-for-next-poll) and the nash-only refusal. The exec op's streaming engine was subsequently rebuilt as ExecSession (VMAgentCore, one poll loop, testable over a socketpair): the shell now spawns as its own process-group leader (signal frames and host-hangup kills reach the whole command tree, kill(-pid)), and once the shell exits the exit frame waits at most a short pipe-drain grace instead of blocking on pipe EOF forever — the wedge where a forking shell's surviving descendants (any backgrounded child, or orphans after a pid-only kill) held the write-ends open and mac_vm_exec never got its exit frame. zsh mostly masked this by execing single commands in place; nash (brush) always forks, which is why the swap surfaced it. Regression suite: ExecSessionTests (background-child, group-kill, stdin, spawn-mechanics — serialized, 10/10 repeat-run clean). The darwin-binary pipeline now exists end to end: .github/workflows/nash.yml builds the arm64 darwin slice on a macOS runner (native cargo test as the gate) and publishes the GHCR OCI single-blob artifact nucleic-nash-darwin (crate-version + latest tags); scripts/build-nash-macos.sh is the same build for a dev host; scripts/package-app.sh embeds the binary at Resources/macvm/nash (signed in the inside-out pass); and MacVMEngine+Provision.swift (resolveNashBinary) stages it next to the provisioner in the share, so the next base rebuild installs it — and staging is now REQUIRED (host resolveNashBinary guard + Phase 5⅓ hard-fail): a base without nash would have a dead exec surface, since the guest refuses to run commands under anything else. The §10.6 darwin gate runs (differential harness on macOS + the dogfood metric) remain owed, but as validation debt against the live surface — they are no longer an activation gate, because nash on the macOS VM is forced by decision (2026-07-23), and the one darwin hang once pinned on nash proved to be the TCC network-volumes prompt. M2: the Gate gained redirect/cmdsub/pipe hooks. nash reads back the affected byte range of every regular-file redirection (> head, >> delta, </2>/&> head), captures heredoc/here-string inline, captures $(…) output, and tees every a | b pipe link — all bounded (64 KiB), redacted in-guest, hashed, previewed base64. Transparency proven, not asserted: regular files keep the real fd (a dd seek= write is byte-identical to bash); a tapped pipe passes binary byte-for-byte (md5 matches bash); yes | head gets SIGPIPE and terminates (no hang); a 10 MB stream reports the full total with bounded memory; special files (/dev/null, ttys, fifos) are metadata-only. The pipe tee is only installed when observation is active — no extra pipe/thread on the unobserved fast path. Verified: 16/16 Rust integration tests, corpus 100% with all taps active (redirect + cmdsub + 32 pipe events captured, output byte-identical to bash), brush compat suite unchanged (1684/6). Swift: redirect/cmdsub/pipe decode into ShellReportCall (48/48 tests), logged via CMDTRACE pending the §9.5 feed rows. The macOS host surface (§7.7, milestone M5.5) is implemented behind its settings, which default to the status quo — it reverses this doc's original "user's real Mac excluded" non-goal, narrowed to Nucleic-spawned host shells with no divert, no install outside the app bundle, and no change to the user's own shells. Landed: LoginShellEnv (§7.7.2) — the memoized, sentinel-fenced $SHELL -ilc 'env -0' snapshot, diffed against the app env, filtered (PATH / shell noise / credential-shaped keys), and injected as an underlay at the ChildProcess chokepoint (explicit spec values, app env, and the Managed Git pins always win; removeFromEnvironment runs after injection) behind nucleic.host.loginEnvSnapshot; CommandInterceptor.hostShellArgv (§7.7.6) — bundled-nash -c when nucleic.host.shell = nash and Nucleic.app/Contents/MacOS/nash exists (embedded + hardened-runtime-signed by package-app.sh; dev fallback to shell/dist/macos/nash), silent total degradation to /bin/zsh -lc otherwise; the nucleic.host.* settings (§9.6: shell default zsh until the gates pass, shellCapture default meta per §7.7.4, loginEnvSnapshot, legacyShell as the revert-everything lever); the three spawn sites flipped through the helper — runOnHost, the host monitor branch, and BuildRunPanel.run (the latter observation-silent until the §7.7.3 HostShellEventListener exists); the widened /shell-event route gate (third disjunct: a Mac host run with the host shell enabled), with the control triple parked on the backend actor for runOnHost/monitors; and the shell:"zsh" per-call escape hatch on host_exec plus the tool description's "the host shell is bash-compatible" line. One beyond-plan addition: a containerized run's host_exec (exactly §1 goal 5's case) wires nash at the run's control-socket host path via NUCLEIC_SHELL_SOCKET, since such runs have no loopback TCP listener. The HostShellEventListener (§7.7.3) is also in: an AppStore-owned shared listener wrapping a dedicated approval-server instance (the tested /shell-event route + bearer gate), lazily bound on the first registered spawn, per-spawn tokens retired after each run (late/forged posts 401; no spool by design), delivering into AppStore.observeAppScopedShellEvent under synthetic origin ids (buildrun:<projectID>) — Build/Run now wires nash observation through it (HostShellEventListenerTests, 2/2, real loopback HTTP incl. wrong-token refusal and origin attribution). And the §10.6 differential replay harness exists: shell/corpus/host_differential.py replays each corpus command under zsh -lc vs nash -c in identical disposable workspaces (or a --worktree clone), diffing exit/stdout/filesystem (stderr informational), with explicit excluded:<reason> reporting and built-in self-test entries (word splitting, no-match globbing, echo escapes) the diff engine must catch or the run fails; extract_host_corpus.py sweeps transcripts for real host_exec commands and auto-classifies them into host-corpus.jsonl. Validated end-to-end against real nash (Linux build, naros-agent) vs zsh 5.9: starter corpus 11/11 parity, self-tests 3/3 caught. Verified Swift-side: LoginShellEnvTests + the host-shell ContainerSandboxTests + MacVMTests (158/158 across the three suites); full package builds. Still open for M5.5: the darwin universal binary actually shipping (shared with M5 — nash.yml must run and package-app.sh embed its output), the macOS gate run of the differential harness (real corpus + darwin nash + BSD userland; the Linux validation proves the harness, not the platform), the Terminal pane (stays §7.7.5-gated), and the LoginShellEnv + zero-fallback dogfood weeks before nucleic.host.shell flips to nash by default.

Prior milestone detail: M0: vendored brush (brush-shell-v0.4.0) at third_party/brush/; replay harness at shell/corpus/ reports 94/94 = 100% parity vs bash after fixing fork divergence D1 (shell/corpus/DIVERGENCES.md). M1 (Rust half): the Gate trait is live in brush-core (allow-all, verdict-shaped, §4.2 as locked); nash-observe emits exec/cd/export-names/fallback/dropped events with near-live batching over unix-socket HTTP → TCP → JSONL spool; the -c parse-failure fallback re-execs preserved real bash after posting a fallback event; the kill switch works (an env var at M2; the §4.3 policy file since). Verified: 8/8 end-to-end observation tests (shell/nash/tests/observe.rs), corpus 100% with capture off AND on, brush compat suite unchanged vs baseline (1684/6/377/38 — environmental failures only). M1 (Swift half) also complete: /shell-event route + ShellReportCall decoding (parseShellBatch, 3 tests) → registerShellReport in all four backends → ConflictCoordinatorAppStore.observeShellEvent (exec events converge onto the command/git/gh feeds tagged source: .nash; cd/export/fallback/dropped CMDTRACE-logged until M2's feed rows); hookEnv now wires NUCLEIC_SHELL_HOOK_URL + NUCLEIC_SHELL_CAPTURE always (tracer demoted to opt-in legacy); linux_container + monitor execs use CommandInterceptor.agentShellArgv — a probe that prefers /usr/local/bin/nash and degrades to /bin/sh (§7.6), so the switch activates the moment nash lands in an image or seed. Wire contract cross-checked against a real nash spool batch. Full package builds; MCPApprovalServer suites 33/33. Remaining for M3: nash in the sandbox image (divert) and boot-time seeding (§7.1, §8) — image-side delivery now rides narOS (NAROS.md): the planned v8 sandbox image is superseded by naros-agent, whose first stable release (narOS milestone N2) is the M3 vehicle.

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, and — argv-only, for Nucleic-spawned shells only — the macOS host itself), build/distribution, testing, rollout phases, and risks.

Related docs: NAROS.md (the distro that bakes and forces nash on every Linux surface — narOS v1 is the M3 vehicle; §7.1/§7.4/§7.6/§8 below now deliver through it), HOST_EXEC_CONCURRENCY.md (the other consumer of host command structure; §7.7 feeds it), 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 (nash-only exec surface — no zsh fallback, no wire override; a nash-less guest refuses, §7.5); the user's real Mac in scope but argv-only — every Nucleic-spawned host shell (host_exec, host monitors, Build/Run, and — last, separately gated — the Terminal panel), never a divert, never the user's own shells (§7.7)
Host forcing mechanism Spawn-site argv only. macOS system shells are SIP-protected and belong to the user; nash replaces /bin/zsh -lc at Nucleic's own ProcessSpec sites and nowhere else. LoginShellPATH's probe stays on the user's real $SHELL by definition (§7.7)
Host login-shell env Snapshot-and-injectnash -c under an inherited env, with the user's login-shell exported env captured once via $SHELL -ilc and merged in (LoginShellEnv, generalizing LoginShellPATH), rather than asking nash to emulate zsh's rc chain (§7.7.2)
Host capture default meta, not tap — the host filesystem is the user's, so data-flow previews are opt-in there; the container default stays tap (§7.7.4)
Host distribution In-app-bundle binary (Nucleic.app/Contents/MacOS/nash), signed/notarized with the app. No installer, no /usr/local/bin write, no PATH change on the user's Mac (§8)
Fork home In-repo subtreeshell/ 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 onlyexport/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

  1. 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.
  2. 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.
  3. 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.
  4. Forced default everywhere Nucleic controls the OS image: sandbox containers, the cloud runner container, agent-created linux_containers, the Linux VM, the macOS VM.
  5. No observability cliff at the sandbox escape. host_exec is the one path by which an agent's command leaves every observed surface and runs on the shared macOS host — precisely the commands that matter most (builds, signing, deploys, git push from the real machine), and today the only ones with no exec events, no data-flow capture, and no structured argv. Every shell Nucleic itself spawns on the host runs under nash and reports on the same pipeline, so the feed does not go dark at the moment the blast radius gets largest (§7.7).
  6. Subsume the tracer, converge the shims. The BASH_ENV command 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 Mac as a machine. §7.7 brings the host's Nucleic-spawned shells under nash, but nothing about the operator's own environment changes: no dpkg-divert analogue (macOS system shells are SIP-protected and are the user's anyway), no /usr/local/bin install, no chsh, no PATH edit, no touching ~/.zshrc. A terminal the user opens themselves is untouched, and LoginShellPATH (LoginShellPATH.swift:95-109) keeps probing the user's real $SHELL — reading the user's rc files is its entire purpose, so nash could not replace it even in principle. nash on the host is an argv substitution at Nucleic's own ProcessSpec sites, nothing more.
  • host_exec's guardrail. nash observes; it does not gate. HostExecPolicy.swift (classify at :86, the dotenv block at :127, the reason gate at :163) and the approval prompt remain the enforcement layer for the sandbox escape, exactly as today. §7.7 adds no policy and removes none — consistent with the v1 non-goal below.
  • 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 still exec /usr/bin/dash.real — or, with the sandbox's passwordless sudo, edit nash's own policy file; 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. This is not a licence for opt-out levers: §4.3 draws the line at levers nash itself reads, which must come from a channel the observed process does not own. "Not a security boundary" means we don't claim to stop a determined agent; it never meant handing one an env var that turns the shell's observation off in a word.

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::Shell is an embeddable engine; the workspace splits brush-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/aarch64 Linux (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
os/                           # narOS build (NAROS.md) — bakes nash + the divert into
                              #   the naros-agent image (§7.1), superseding a
                              #   nucleic-sandbox Dockerfile change
.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 pipefail passthrough. Login (-l) sources /etc/profile + profile.d so the existing nucleic-path.sh PATH setup keeps working. -c follows bash's rule that short options keep parsing after it and the command is the first operand, so nash -c -l <cmd> / nash -c -e -u <cmd> run <cmd> (the fork normalizes the ordering before clap sees it; clap alone would reject them for a missing -c value).
  • 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 present
    • NUCLEIC_HOOK_TOKEN, NUCLEIC_SESSION_ID — existing auth/session vars
    • NUCLEIC_SHELL_SPOOL — spool dir for offline batches (default /var/spool/nucleic-nash)
    • NUCLEIC_SHELL_CAPTUREoff | meta | tap (default tap; see §5). Under a require_observation policy (§4.3) off from the environment is refused.
  • Reads its levers from the trusted policy file, never from the environment (§4.3): the kill switch (disable=1 — immediately exec the real bash with identical argv, the "get out of jail" lever for compat emergencies) and that exec's target (real_bash, default /usr/bin/bash.real falling back to /bin/bash). NUCLEIC_NASH_DISABLE / NUCLEIC_REAL_BASH / NUCLEIC_FALLBACK_SHELL in the environment are ignored and reported as policy events.
  • Parse-failure fallback: if brush-parser rejects input that looks like valid bash (parse error on -c input or a sourced file), nash emits a fallback event 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).

4.3 The trusted policy file (operator levers are not environment)

The rule: nash's levers are read from a root-owned file, never from the environment.

The environment is the agent's. It writes its own command line, exports at will, and has passwordless sudo in the sandbox. So for as long as the kill switch was NUCLEIC_NASH_DISABLE=1, the layer whose entire job is to watch the agent's commands could be switched off by one of those commands — and silently, since the switch was read before observation was installed and emitted nothing. Three variants of the same hole existed:

Bypass What it did
NUCLEIC_NASH_DISABLE=1 <cmd> exec'd the real bash, no event
NUCLEIC_REAL_BASH=<path> chose what nash re-execs into, for the kill switch and the parse-failure fallback
NUCLEIC_SHELL_CAPTURE=off, or env -u NUCLEIC_SHELL_HOOK_URL … left nash running as an ordinary shell that reported nothing — the quietest of the three

The levers now live in /etc/nucleic/nash.conf (macOS guests: /Library/Application Support/Nucleic/nash.conf), a key=value file nash honors only when it is a regular file (not a symlink), owned by uid 0, and not group/other-writable. A file that fails any of those is ignored and reported. It is 0644, not 0600 — nash runs as the agent uid and must read it — so this is a trust boundary on writes, and the file holds no secret.

Key Meaning
disable=1 the §4.1 break-glass: exec the real bash with identical argv
real_bash=<path> exec target for the break-glass and the parse-failure fallback
require_observation=1 observation may not be turned off by the observed process: NUCLEIC_SHELL_CAPTURE=off from the environment is refused, and a shell that finds no transport configured spools to /var/spool/nucleic-nash for the drain (§6.2) instead of running silent

Who writes it: narOS bakes it (nash-default-shell), and ContainerEngine.seed rewrites it as root at container start, carrying ContainerServiceSettings.legacyShell into disable. Because that lever no longer travels as an env var, it is part of the seed recipe stamp — flipping it invalidates a reused clone's seed so the next start rewrites the file, which is what keeps the emergency lever reaching containers that already exist. The macOS VM guest agent reads the same file for its own shell choice (ExecSession.shellInvocation), so the daemon and every shell below it cannot disagree about whether observation is on.

The policy event. Every lever nash sees becomes a feed row rather than a silence: action: "ignored" for one that came through the environment (with the lever name and value, so the attempt is legible), action: "honored" for one the trusted file set — including the break-glass itself, flushed before nash execs away, so "this shell went dark deliberately at 10:04" is a row and not an absence of rows.

What this is and isn't. It is a bar, not a wall, and §1's non-goal stands: the agent has sudo, so it can still edit this file, exec /usr/bin/bash.real directly, or run its work under python3 -c. What it can no longer do is go dark by accident, or by typing one variable, without leaving a record. Closing the rest means moving the boundary outside the sandbox — dropping the sudoers drop-in, root-only .real binaries, or exec auditing in the container runtime — which is a separate decision with its own cost to agent workflows.


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, durationMs
  • pipeline: {id, index, len} when part of a | b | c
  • nesting: {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 (no BASH_ENV inheritance games).

In-flight commands (exec-start). An exec event is by construction a past-tense event: it carries the exit code, so it cannot exist until the command is over. That left the feed silent for the whole duration of exactly the commands worth watching — a five-minute build appeared as a row five minutes after it started. So a command that is still running after ANNOUNCE_AFTER (400 ms) is announced with {kind: "exec-start", token, elapsedMs, argv, cwd, pipeline?} — argv and cwd only, because nothing else is known yet, plus how long it has been running so far. elapsedMs is what lets a viewer count elapsed time from the command's own start: a row clocked from the moment the event arrived would under-report by the announce threshold plus the flush it waited on, and would carry that error for as long as it counted. Both halves carry the gate token, unique per nash process (pair it with the batch's shellId), and that join is what lets a host show one row that starts as in-flight and becomes the finished row in place rather than two rows per command.

Two properties make this cheap enough to be on by default:

  • Only slow commands pay. The announcement is swept out of the pending-exec map by the flusher thread, not emitted from the gate, so a command that returns inside 400 ms costs no extra event at all — the common case adds nothing to the wire. The sweep ticks only while an un-announced command is actually in flight; an idle shell parks exactly as it did before.
  • Never on the command's path. The sweep runs on the flusher thread and takes only the same pending lock the gate already uses, so a contended or slow sweep can delay an event, never a command (§5.5).

A host must treat the announcement as provisional: a shell killed mid-command (SIGKILL, a VM torn down) never sends the completion, so an in-flight row is a claim about a live process and nothing more — it is never persisted, and never restored into a later run (§9.4).

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 sizeAfter and read back the affected range (for >: 0..min(size, CAP); for >>: sizeBefore..sizeBefore+CAP; for <: head of the file), attaching bytes, truncated, hash (FNV-1a, not a cryptographic digest — this is a cheap did-these-bytes-repeat marker, not an integrity claim), and a preview (first CAP bytes, base64) to the event.
  • CAP default 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, fromText, toText, bytes, truncated, preview, hash} via mechanism (b). This surfaces what the transcript never shows today — the intermediate data between stages (curl … | sh becomes visible).

How the copy runs (docs/NASH_STREAM_PERF_PLAN.md §P2): only the captured prefix passes through userspace. Once CAP bytes are mirrored the copier switches to splice(2) on Linux — pipe-to-pipe inside the kernel, with the byte count coming back from the same call that moves them — and falls back to a large-buffer read/write loop on macOS or wherever the kernel refuses the splice. Both ends of a tapped link are grown with F_SETPIPE_SZ where that is allowed, and the copier threads are pooled per process rather than spawned per link, since thread creation was most of what a tapped pipeline cost to set up.

fromText/toText name the commands either side of the link, rendered from their AST when the tee is spawned. They are carried on the link rather than inferred from the stages' exec events for two reasons: a stage that is a compound command ({ a; b; } | c) emits one exec per command inside it and none of them says which owned the pipe, and a link is reported the moment its producer closes — which can beat that producer's own exec event to the host. Rendering the stage answers both outright. The text is pre-expansion, so curl -H "Bearer $TOKEN" | sh names the stage with $TOKEN rather than the expanded secret an argv-derived name would carry; it is whitespace- collapsed and capped (200 chars) in brush-core, then redacted with every other outbound string in nash-observe (§5.4). Exec events additionally carry pipeline: {id, index, len} (§5.1), which is what supplies each stage's exit code.

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. The per-batch cap is spent in arrival order, so a pipeline storm loses its tail of previews and never an event: every link still reports its byte count and its hash.
  • Hashes cover the captured prefix, uniformly across the three data-flow kinds. A pipe's full stream is never buffered, so it never could cover more; making the file and cmdsub kinds agree keeps the marker's meaning single ("these previews carried the same bytes") and keeps the hash off the paths a command waits on.
  • 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 KEY blocks) in previews; env vars are never captured wholesale (exec events carry argv, not environment).
  • Where that work runs (docs/NASH_STREAM_PERF_PLAN.md §P3): a command's own path pays only for capture — the bytes, plus one stat to fix which range of a redirected file this command wrote. Reading that range back, redacting, base64-ing and hashing all happen on the flusher thread as the batch is serialized. The range is measured at exit rather than at flush precisely so that echo a > f; echo b >> f still reports one line each: the reading is deferred, the measurement never is.
  • Environment mutations are names only (locked decision): export/declare/unset produce 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 dropped counter 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.52 s budget) and disk spool fallback; panics in nash-observe are caught at the hook boundary; and the kill switch (disable=1 in the trusted policy, §4.3) plus the preserved real bash mean any regression has a one-line mitigation while we fix forward. That the switch is now a root-owned file rather than an env var costs the operator a sudo and buys the property that the observed process cannot pull it.


5.6 Raw-shell writes join the lock system (instead of being refused)

A shell command that authors a file — echo … > f, cat > f, sed -i, tee, dd of=, truncate — emits no fileChange, so it used to skip both halves of what a structured edit gets: the conflict lock on its paths and the per-edit trunk commit. The approval layer answered that by denying those commands outright (WorktreeMutationGuard) and steering the agent to Edit/Write. That was a blunt answer to a real problem: echo … >> CHANGELOG.md is ordinary, legitimate shell, and refusing it costs a round trip on work the agent had every right to do.

With nash in place there is a better answer — allow them safely, through the same stack:

  1. Before the command runs, the approval gate asks WorktreeMutationGuard which worktree files the command writes and arbitrates exactly those paths through ConflictCoordinator.arbitrateShellWritesAppStore.arbitrate — the same seam Edit/Write use (LOCKING §4.2). A contended file queues the Bash call just as it queues an edit; a defer/cancel comes back as a denial; an update-on-grant re-ground blocks the command once so the agent re-reads before it overwrites (LOCKING §4.5). Relative targets are resolved against the run's workdir (the container workdir, which is the canonicalized worktree path), and the walk follows cd across &&/; so cd Sources && echo x > main.swift locks Sources/main.swift and not a bare main.swift.
  2. After the command runs, the redirect event (§5.2) is the ground truth: nash reports the path the shell actually opened, so AppStore.claimShellWrite takes the lock for anything the static parse could not see (a path built at runtime, a write from inside a script, a nested shell) and lands the bytes into the trunk. This half matters on its own: echo/cat/sed/ tee all classify as non-mutating commands, so the exec-driven residue sweep never fired for them — the redirection is the mutation, and this is where it is seen. The post-hoc claim is deliberately non-blocking: the write already happened, so there is nothing left to gate, and a file another session holds is left to that session (the residue sweep skips it) rather than queueing behind it.

For this to work the redirect event reports its target as an absolute path — a redirect event carries no cwd of its own (unlike exec), and the host must map the path to a worktree file, and thus to a lock. nash-observe resolves it against the command's cwd, purely lexically: the target of a truncating write may not exist yet, and observation may never stat its way into a slow path.

One denial survives, and it is a statement about lockability, not about shell: a mutation whose target cannot be named before the command runs — patch applying a diff, a glob, an unexpanded $VAR, a cd $DIR the walk can't follow — is refused, because a lock needs a path. patch with an explicit file operand is fine. Writes outside the worktree (sudo tee /etc/hosts) are not worktree mutations, so the lock system does not claim them; HostExecPolicy and the risk classifier remain their gate, per §1's non-goal.

Where step 1 runs, per provider (2026-07-30). "The approval gate" is Claude's canUseTool bridge and Grok's session/request_permission — both of which see every tool call. Codex sees neither: it only asks about a command its approval policy doesn't already allow, which under Nucleic's config is never (LOCKING §4.6). Its Bash calls reach arbitrateShellWrites through the container's PreToolUse hook instead (CodexToolGate, ADAPTERS §2.7) — codex normalizes its shell tool to tool_name: "Bash" with the command line in tool_input.command, so everything in this section applies verbatim, including the one surviving denial below.

This does not turn nash into an enforcement point: the Gate still returns Allow unconditionally, there is still no synchronous host round-trip on the command path, and POST /shell-gate remains reserved and unbuilt (§4.2). The gating happens where it already happened — in the approval layer, before the tool call — using nash's model of what a shell command does and nash's report of what it did.

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
  "environmentKind": "agent",  // agent | host | mac-vm | linux-vm | container | app
  "environmentId": "agent",    // stable picker identity within the session
  "environmentLabel": "Agent", // operator-facing Nash Viewer label
  "shellId": "…",              // random per nash process; correlates events
  "parentShell": "…|null",     // shellId inherited via env → process tree of shells
  "events": [
    // Only for a command still running after ANNOUNCE_AFTER; joined to its `exec` by `token` (§5.1).
    { "kind": "exec-start", "seq": 0, "ts": 1721, "token": 12, "elapsedMs": 412,
      "argv": ["git","push"], "cwd": "/workspace", "pipeline": null },
    { "kind": "exec", "seq": 1, "ts": 1721, "token": 12, "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, "hash": "…", "previewB64": "…" },
    { "kind": "pipe", "seq": 3, "pipelineId": "p1", "fromIndex": 0, "toIndex": 1,
      "fromText": "curl -sL https://x.sh", "toText": "sh",
      "bytes": 90210, "truncated": true, "previewB64": "…", "hash": "…" },
    { "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, and a command that outlives a flush window announces itself as in-flight rather than waiting for its own exit (exec-start, §5.1). seq is strictly ordered per shellId, so a true streaming transport can be swapped in later without changing the event model.

A surface whose transport is the spool-and-drain (the Linux and macOS VMs, §6.2) delivers the announcement only when the host next drains, so an in-flight row there is as live as the drain is — the event model is the same, the liveness is a property of the transport.

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).
macOS host (§7.7) Direct loopback POST — the simplest surface of all. No vsock, no bridge, no spool: nash is a child of the app on the same machine as the listener, so it posts straight to http://127.0.0.1:<port>/shell-event. Session-scoped spawns (host_exec, host monitors) use that run's own approval server (MCPApprovalServer.start(host: "127.0.0.1"), ClaudeCodeBackend.swift:815-825) — the same controlHost/port/token triple the narOS host-run branch already passes to hookEnv (:1066-1072). App-scoped spawns (Build/Run, Terminal) have no session server; see §7.7.3.

Auth is the existing per-session bearer token (NUCLEIC_HOOK_TOKEN); unknown tokens get 401 and nash stops posting (spools) for that process.

The connection is kept open across batches (docs/NASH_STREAM_PERF_PLAN.md §P4.1): a shell posts every 500 ms for as long as it lives, and the host's route already answers Connection: keep-alive with a Content-Length. nash reads each response to its end, so the socket stays framed for the next batch; a connection the host has since closed costs one retry on a fresh one, after which the ordinary spool fallback applies.


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.

One surface deliberately gets a weaker definition: on the user's real Mac (§7.7) only clause (2) applies. There is no divert and no $SHELL change — the OS image isn't ours, so "forced" there means "every shell Nucleic starts", not "every shell that runs".

7.1 Sandbox container image (base for runner and linux_container)

Delivery update (NAROS.md): these mechanics now ship as narOS build steps rather than sandbox-Dockerfile layers — the binary installs from the nash .deb and the divert block below becomes the nash-default-shell package's maintainer scripts, baked into the naros-agent image (NAROS §3.1, §4). The fragment is kept as the semantic reference for what the image must contain:

# 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
# The fallback target is a §4.3 policy key (`real_bash`), never an ENV — an env lever is
# one the agent can rewrite. /etc/nucleic/nash.conf ships in the same package.

(Debian merged-usr: /bin/sh symlink handling verified in M0; dpkg-divert keeps apt upgrades from clobbering the links.)

Version lockstep: the four-place v7 → v8 bump originally planned here (Dockerfile content, sandbox-image.yml:36 IMAGE_TAG, Project.swift:119 defaultImage, nucleic-runner/Dockerfile:38 base pin) is superseded by narOS — it collapses to the single os/VERSION constant plus the Project.swift pin flip to ghcr.io/abkslm/naros-agent:<stable>, with a CI assertion that the pin matches a published stable tag (NAROS §8). containers/nucleic-sandbox/ retires into os/images/agent/; the runner rebases FROM naros-agent (NAROS §7.2).

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.

Implemented with narOS N3 (NAROS.md §7.2). The runner's one wrinkle vs the sandbox: its sessions are host runs (RunSpec.container = nil — the container is the sandbox), so no ContainerSpec seeding and no shims, and the containerized hookEnv merge never fires. Delivered instead: CommandInterceptor.hostIsNarOS (/etc/os-release ID=naros) keys a host-run variant in ClaudeCodeBackend — the report routes register and hookEnv (tracer off; legacyShell now reaches nash through the §4.3 policy file) merges into the host spawn env, pointed at the run's loopback approval server. nash exec/data-flow events reach the feed as on any surface, and the §9.4 argv convergence (observeShellEvent → git/gh ops) substitutes for the absent shims. Codex/Grok host runs start no approval server today, so their runner sessions stay observation-silent — a follow-up, not a regression (they were equally silent pre-narOS).

7.4 Linux VM

Site Today Change
provision-linux-guest.sh (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

With narOS N4 (NAROS.md §7.4) the guest rootfs is narOS, so nash forcing here is entirely a rootfs property: nash + the /bin/sh→nash divert arrive baked via the naros-tier-vm meta-package, exactly as on the sandbox/runner tiers. Because the divert makes /bin/sh resolve to nash, the agent's existing execl("/bin/sh"…) (agent.c:440) already lands in nash — no agent.c exec swap is needed, and the old hand-installed provisioner steps (useradd -s nash, fetch-and-install nash, apply the divert) collapse into the tier. The only VM-specific M4 work left is the spool-drain op (§6.2). The GNOME 50 desktop flavor rides narOS N5.

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/nash in scripts/provision-macos-guest.sh (required — the provision hard-fails without a staged nash); mirror /etc/zshenv's PATH into /etc/profile.d-equivalent sourcing that nash -l reads.
  • Ops+Exec.swift runs /usr/local/bin/nash -lc exclusively (locked 2026-07-23: nash is the only agent-accessible shell on every virtualized surface). There is no wire-level shell override — the former shell:"zsh" escape hatch is removed — and a nash-less guest refuses the exec with an actionable spawnError rather than degrading. The agent account's login shell is also set to nash via dscl during provisioning. In-shell zsh-ism compat rides §4.1's own doctrine (the policy's real_bash defaults to /bin/zsh on macOS for the parse-failure re-exec), and disable=1 in the guest's /Library/Application Support/Nucleic/nash.conf (§4.3) remains the in-guest operator break-glass.
  • The §10.6 darwin gate runs (differential harness + dogfood metric) remain owed as validation debt against this now-live surface.

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.

narOS adds a fast path and an upgrade path (NAROS §7.3): the probe first checks /etc/os-release for ID=naros — nash, the bridge, and node are then known-present and the probe is done — and any Debian-family custom image can be converted in place with apt install naros-keyring naros-tier-agent. The seed/degrade path above remains for everything else.

7.7 The macOS host — Nucleic-spawned shells

The one surface where Nucleic does not own the OS, and the one where the observability gap hurts most: host_exec is the deliberate sandbox escape (§1 goal 5). A containerized agent's swift build produces exec events, redirect taps, and pipe tees; the same agent's host_exec("swift build") produces a transcript blob and nothing else. Everything §5 captures disappears at exactly the call that reaches the shared machine.

nash closes that gap without becoming a fact about the user's Mac. The mechanism is narrow by construction: Nucleic replaces /bin/zsh -lc with nash in its own ProcessSpecs. No divert (SIP forbids it, and /bin/sh on the user's Mac is not ours to redefine), no $SHELL change, no chsh, no install outside the app bundle. A shell the user starts is a shell nash never sees.

7.7.1 Inventory: which host shells, and which stay zsh

Four /bin/zsh spawn sites plus one probe. They do not all get the same treatment, because they are not all the same kind of shell:

Site Today Disposition
ClaudeCodeBackend.runOnHost (:3065-3067) — host_exec /bin/zsh -lc nash (M5.5 core). Agent-authored, non-interactive, already approval-gated — the whole point of the section
ClaudeCodeBackend.spawnMonitorProcess host branch (:1679-1686) /bin/zsh -lc nash. Its container twin already uses CommandInterceptor.agentShellArgv (:1677), so this is the arm that makes the two branches symmetric
BuildRunPanel.run (:238) /bin/zsh -lc nash. User-authored but Nucleic-spawned and non-interactive; its comment already says it mirrors runOnHost (:232), so it should keep mirroring it
TerminalPanel (:56-61) $SHELL -l in a pty nash last, separately gated — see §7.7.5. A human at a prompt, not an agent
LoginShellPATH.resolve (:95-109) $SHELL -ilc Stays zsh, permanently. It exists to read the user's rc files; running it under nash would defeat its purpose and break provider discovery

The first three share one shape — Nucleic hands a command string to a non-interactive shell — and should share one helper (§7.7.6), the host analogue of agentShellArgv.

7.7.2 The real problem: -lc is doing work we'd be dropping

The naive change (/bin/zsh -lcnash -lc) is wrong, and it's worth being precise about why, because this — not brush's bash compatibility — is the hard part of the host surface.

zsh -l sources /etc/zshenv, /etc/zprofile, ~/.zprofile, ~/.zlogin. nash -l sources /etc/profile + profile.d + ~/.profile, which on a typical Mac do not exist. So nash -lc "swift build" would run with launchd's minimal environment: no Homebrew, no asdf/nvm/rbenv shims, no Xcode toolchain override, no ANDROID_HOME. Commands would fail with command not found — a far more visible regression than any brush parse gap, and one that has nothing to do with the shell's correctness.

Half of this is already solved: ChildProcess widens PATH for every host spawn via LoginShellPATH.augment at the single chokepoint (ProcessHost.swift:246-263), using a memoized $SHELL -ilc probe. PATH would survive the switch untouched.

The other half — non-PATH exported state from the user's rc files — would not. The fix generalizes the mechanism that already works:

  • LoginShellEnv, a sibling of LoginShellPATH using the identical shape (memoized, sentinel-fenced, 5 s timeout, injectable runner for tests): run $SHELL -ilc 'env -0' once per app launch, diff against the app's own environment, and keep the delta.
  • Inject that delta beneath spec.env at the same chokepoint, so an explicit ProcessSpec value always wins and the Managed Git pins (GIT_SSH_COMMAND, signing config from GitHubCredentialProvisioner.provisionHost()) are never clobbered.
  • Filter the delta: drop PATH (already handled), shell-internal noise (SHLVL, PWD, _, OLDPWD, ZSH*), and anything on the existing removeFromEnvironment credential list.
  • Then spawn nash -c, not -lc. With the env snapshot injected, login-shell sourcing has nothing left to contribute, and skipping it is strictly faster than today's zsh -lc (which re-sources the user's rc files on every host_exec).

This is a net improvement independent of nash: it makes host spawns deterministic and removes per-command rc-sourcing latency. It is also the piece most likely to surface surprises in dogfood, so it lands and bakes before the argv flip, behind its own setting.

7.7.3 Wiring: env, transport, attribution

Session-scoped spawns (host_exec, host monitors) already have everything needed — the narOS host-run branch (ClaudeCodeBackend.swift:1066-1072) is the exact pattern, and the registerShellReport gate at :843-866 already fires for host runs. Two changes:

  • Widen the route gate. Today: run.container?.installGitInterceptor == true || (run.container == nil && CommandInterceptor.hostIsNarOS). hostIsNarOS is false on macOS by construction (CommandInterceptor.swift:272), so a Mac host run registers no shell route. Add a third disjunct — host runs on a Mac with the host shell enabled — so /shell-event is live.
  • Reach the triple from runOnHost. controlHost/port/token are locals inside the spawn do block (:790-1100); runOnHost (:3047) can't see them. Store them on the backend actor when the server starts (they're already stable for the run's lifetime) and read them in runOnHost/spawnMonitorProcess. Pure plumbing, no new coupling.

hookEnv is then reused as-is with commandTracing: false — the git/gh keys ride along inertly (no shims on the host; real git ignores them), NUCLEIC_SESSION_ID gives attribution, NUCLEIC_HOOK_TOKEN authenticates, and the legacyShell lever keeps working — via hostShellArgv picking zsh, since the lever no longer rides in this env (§4.3). Only shellCapture differs (§7.7.4).

App-scoped spawns (Build/Run, and later Terminal) have no session and no approval server. Rather than start one, add a small loopback HostShellEventListener owned by AppStore: bound to 127.0.0.1 on an ephemeral port, started lazily on the first such spawn, per-spawn bearer token, and it feeds the same observeShellEvent seam. Events carry a synthetic origin id (buildrun:<projectID>, terminal:<paneID>) instead of a SessionID, so the feed can attribute them without inventing a fake session.

Spool fallback is unnecessary here and should be omitted: an unreachable loopback listener on the same machine means the app is gone, and a spool would then only accumulate the user's own filesystem data with nobody to drain it. On post failure nash drops events (§5.4's dropped counter) and the command runs normally.

7.7.4 Capture posture — the host is not a sandbox

Every other surface taps a filesystem Nucleic created. This one taps the user's own machine: their home directory, their SSH keys, their browser profile, their unrelated work. A host_exec("cat ~/.aws/credentials") under capture=tap would put a preview of a real credential file into the persisted transcript (§9.4) and sync it to the phone (§9.7) — redacted by §5.4's patterns, but the redaction is pattern-based and the file is real.

So the host surface defaults to capture=meta (exec events, cd, export names — no redirect/pipe/cmdsub previews), inverting the container default. tap is available per the nucleic.host.shellCapture setting for users who want full host data-flow, and the same §5.4 caps and in-process redaction apply when they do. Rationale: exec events alone deliver most of §1 goal 5's value — structured argv for the shared-host commands that matter — while the marginal signal from previewing the user's own files carries the most privacy cost of any capture in this plan.

Two consequences worth naming: host data-flow events, when enabled, are marked host-originated end to end so §9.7's phone-side retention can be configured separately; and HostExecPolicy's secretFileBlocked (HostExecPolicy.swift:127) already refuses dotenv reads before a shell ever starts, so the sharpest case is blocked upstream regardless.

7.7.5 Terminal panel — in scope, but not the same problem

TerminalPanel (:56-61) runs $SHELL -l interactively in a pty via SwiftTerm. It is in scope, but it inherits neither the schedule nor the posture of the other three, for two reasons that are worth separating:

  1. nash has no interactive surface today. §1's non-goals strip line editing, completion, and prompt rendering from the build. Dropping that into the user's terminal pane would be a plain downgrade. brush does implement an interactive layer upstream, so the prerequisite is a nash-interactive build flavor (or a feature flag) that re-enables it — real work, and its own gate: line editing, history, job control, and Ctrl-C/Ctrl-Z behavior at parity with zsh before it becomes the pane's default.
  2. It is a human's shell, not an agent's. The user typing into a pane has a reasonable expectation that Nucleic isn't transcribing and syncing their keystrokes. So the panel defaults to capture=off — nash runs, but reports nothing until the user opts in — and even meta there is a deliberate choice, not an inherited default.

Given both, the panel ships behind its own setting, default off, after M5.5's core lands. If the interactive build proves not worth it, the fallback is honest and cheap: leave the pane on $SHELL permanently and record the exclusion here. Nothing else in §7.7 depends on it.

7.7.6 Argv helper, fallback, and escape hatches

The three non-interactive sites share CommandInterceptor.hostShellArgv(_:) — the host twin of agentShellArgv (:248-261), and simpler, since the host has no /bin/sh shim layer to route through: it returns [bundledNashPath, "-c", command] when the bundled binary is present and enabled, and ["/bin/zsh", "-lc", command] otherwise. Degradation is silent and total, exactly as §7.6's container probe degrades — never refuse to run.

The zsh-ism question is sharper here than in the macOS VM (§7.5), because host commands are authored against the user's actual machine. Layered response:

  • Parse-failure fallback (§4.1 doctrine). nash's re-exec target becomes platform-aware: NUCLEIC_FALLBACK_SHELL, defaulting to /bin/bash on Linux and /bin/zsh on macOS. A command using setopt, =~ zsh-style, ** globbing, or $~foo fails to parse, emits a fallback event, and re-execs under zsh before anything has run — safe, since parse failure precedes execution.
  • Silent-divergence is the real risk, and fallback doesn't cover it. A command that parses under nash but means something different under zsh — unquoted word splitting (zsh doesn't split; bash does), no-match glob behavior (zsh errors, bash passes the pattern through) — runs to completion with different semantics and no fallback event. This is the one genuinely new failure mode the host surface introduces, and it argues for the gate below being a differential one, not just a fallback-rate one (§10 item 6).
  • Tell the agent. The host_exec tool description (MCPApprovalServer.swift:1952-1985) gains one line: the host shell is bash-compatible. Cheapest possible mitigation, and it moves the distribution of authored commands rather than catching failures after the fact.
  • Escape hatches. A per-call shell: "zsh" field on host_exec, mirroring the macOS VM wire op (AgentWire.swift:54); the nucleic.host.legacyShell setting reverting all sites to /bin/zsh -lc; and, inside nash itself, the §4.3 policy file's disable=1 (an operator's sudo, not an env var the agent could have set).

7.7.7 What this unlocks beyond the feed

Structured host argv has a second consumer already waiting. HOST_EXEC_CONCURRENCY.md detects colliding host commands by running CommandSummary.classify over the raw command string before it runs — necessarily a fuzzy guess (§3 there). nash reports post-expansion argv for every command in the pipeline, including the ones a wrapper script spawns, so the slot registry can eventually key on what actually ran (the swift build inside a make) rather than the string the agent typed. Same convergence story as §9.4's git/gh argv path retiring the shims — noted here as a downstream opportunity, not scheduled work in this plan.


8. Build and distribution

  • Toolchain: Rust stable, cargo workspace at shell/. Targets: aarch64-unknown-linux-musl, x86_64-unknown-linux-musl (static, no libc drift across Debian/VM rootfs), aarch64-apple-darwin (arm64 only — see §8's darwin note).
  • 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 as nucleic-linux-agent, docs/LINUX_VM.md:98-104), consumed by the sandbox Dockerfile (build stage nash-dist) and the Linux VM provisioner.
    • macOS arm64 binary → artifact consumed by provision-macos-guest.sh (§7.5, staging at scripts/provision-macos-guest.sh:292-329) and embedded in the app bundle (below). This single artifact is the shared dependency of M5 and M5.5. It is built by .github/workflows/nash.yml (macOS runner: native cargo test gate, aarch64-apple-darwin, smoke --version/-lc), published as the GHCR OCI single-blob artifact ghcr.io/abkslm/nucleic-nash-darwin:<ver> (+ latest) — the same pattern as nucleic-linux-vm-kernel — with scripts/build-nash-macos.sh as the shared local build path. scripts/package-app.sh embeds it at Resources/macvm/nash (best-effort: prebuilt shell/dist/macos/nash, else built when cargo exists) and MacVMEngine+Provision.swift stages it into the provisioning share beside provision-macos-guest.sh.
  • Host distribution (locked decision: in-bundle): the host copy ships as Nucleic.app/Contents/MacOS/nash, not an install to /usr/local/bin. It needs no installer, no admin rights, and no PATH change on the user's machine; its version is the app's version, so the §8 seeding/drift machinery doesn't apply to the host at all — an app update is the update channel. Requirements this imposes: the binary is signed with the app's Team ID under the hardened runtime and notarized with the bundle (an unsigned Mach-O in the bundle fails Gatekeeper outright), it lives in Contents/MacOS/ rather than Resources/ per bundle convention, and an arm64 slice suffices — the app floors at macOS 27 (Package.swift), and macOS 26 was Apple's last Intel release, so there is no x86_64 host to run it on. Resolution is Bundle.main.bundleURL/Contents/MacOS/nash with an existence check; a missing or unexecutable binary degrades to /bin/zsh -lc (§7.7.6), so a stripped or repackaged build is never a hard failure.
  • Debian packaging (narOS): the same CI also emits nash + nash-default-shell .debs into the signed narOS apt repository (NAROS §3) — the deterministic baseline the naros images install at build, and a third in-place update channel (apt upgrade nash) alongside image releases and boot-time seeding.
  • 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 seedRecipe version. 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; SessionController logs 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).

The unit of delivery is the batch, not the event (docs/NASH_STREAM_PERF_PLAN.md §P1). One POST carries up to 200 events, and every per-delivery cost the host pays — the unstructured Task, the hop to the main actor, the viewer's ring append and its @Observable invalidation — is paid once for the whole batch. Delivering per event also silently reordered a batch, because unstructured Tasks do not run in creation order: an exec could land ahead of the exec-start it retires. Handlers therefore take [ShellReportCall], in nash's own order.

  1. Route: shellEventPath = "/shell-event" in MCPApprovalServer.swift (beside gitEventPath:649); dispatch beside lines 1752-1760; handleShellEvent parses shell-batch into [ShellReportCall] (new struct beside CommandReportCall:570), one per event, carrying the typed payload (exec / redirect / pipe / cd / fallback / dropped).
  2. Registration: registerShellReport(token:handler:) beside registerCommandReport (1410-1425); wired in all four backends (ClaudeCodeBackend.swift:2721-2744 and Codex/Grok twins).
  3. Coordinator: ConflictCoordinator.observeShellEvents(sessionID:calls:) forwarding to AppStore.
  4. AppStore: observeShellEvents
    • exec events flow into the existing observeCommand path (AppStore.swift:4666) via CommandSummary.classify(argv:), tagged source: .nash, preserving the NVRSION residue-capture backstop (4679-4689) and CMDTRACE logging;

    • exec events whose argv[0] is git/gh also feed observeGitOp/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/pipe events land in a new DataFlowEvent value type in ControlPanel.swift (beside CommandInterceptorEvent: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).

      As shipped, the durable half generalized past the two data-flow kinds: every emission the Nash Viewer shows is persisted, since a redirect's history is worth little without the commands around it. NashEventLog (the in-memory ring, §9.5) writes each row through NashEventPersisting into the nash_event table (GRDBMetadataStore+NashEvents.swift, migration v35) and reads a session's previous runs back when its chat is opened, so the panel opens on history instead of empty. Writes are buffered and flushed in batches — a transaction per row would put SQLite in the path of every pipe the agent opens. Retention is NashRetention: a per-session row cap plus a global preview-byte budget, both oldest-first, applied in the same transaction as the insert. Rows carry the app run that wrote them, which is what keeps a session that emitted while its chat was closed from being restored on top of the live rows it already has. No nucleic.container.shellRetention setting yet — the bounds are defaults on NashRetention.

  5. UI (locked decision: feed rows + detail popover): ControlPanelView.swift activity 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. fallback events 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.
  6. Settings: nucleic.container.commandTracing (Project.swift:572-576) is superseded by nucleic.container.shellCapture = off | meta | tap (default tap), mapped to NUCLEIC_SHELL_CAPTURE. nucleic.container.shellRetention bounds the persisted data-flow history (per-session rows + global size budget, §9.4). A separate rollback lever nucleic.container.legacyShell (default false) seeds disable=1 into the container's §4.3 policy file and restores the tracer wiring — one toggle to fully revert behavior without an image rollback. The host surface (§7.7) gets its own namespace, deliberately not sharing the container keys — a container rollback and a host rollback are different decisions: nucleic.host.shell (nash | zsh, default zsh until M5.5's gates pass), nucleic.host.shellCapture (default meta, §7.7.4), nucleic.host.loginEnvSnapshot (the §7.7.2 LoginShellEnv injection, shippable and revertible on its own), and nucleic.host.terminalShell (default zsh, §7.7.5). nucleic.host.legacyShell is the single revert-everything lever mirroring the container one.
  7. iPhone sync (locked decision: full event sync): shell events enter the normalized AgentEvent/HostMsg stream (SYNC_PROTOCOL.md) as their own event family with the canonical seq cursor, 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 E2EE SecureChannel; 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

  1. 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.
  2. Tap semantics (Rust integration tests, nash-observe): append preserves interleaving under concurrent writers; seek-after-redirect (dd of=…) byte-identical to bash; set -o pipefail exit codes through tees; SIGPIPE propagation through tees (yes | head); binary data through pipes uncorrupted (hash comparison); /dev/null, fifos, 2>&1 dup 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.
  3. 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.
  4. Swift tests: ShellReportParsingTests (batch → calls) beside MCPApprovalServerTests; ContainerSandboxTests extension asserting the naros-agent image resolves /bin/sh → nash and that a linux_container exec produces exec + pipe events end-to-end; dedupe tests for the git-shim coexistence window.
  5. Fault injection: control socket absent (spool fills, commands unaffected); host 401 (posting stops, commands unaffected); nash-observe panic (hook boundary catches; command completes).
  6. Host surface (§7.7) — the corpus here is different in kind, because the risk is semantic divergence rather than parse failure:
    • Differential replay against zsh. Extract the real host_exec command corpus from session transcripts and run each under both zsh -lc and nash -c in a scratch worktree, diffing stdout/stderr/exit and the resulting file-system delta. This is the M5.5 gate, and it must catch the silent cases §7.7.6 names — word splitting and no-match globbing — which a fallback-rate metric cannot see. Commands with side effects run in a disposable clone; non-reproducible ones (network, git push) are classified and excluded explicitly rather than silently skipped.
    • LoginShellEnv (§7.7.2): pure unit tests on the diff/filter (credential keys dropped, PATH/SHLVL/PWD excluded, explicit spec.env wins over the snapshot, Managed Git pins survive); integration test that a host_exec under nash resolves the same swift, brew, and node as one under zsh -lc on a machine using Homebrew + a version manager.
    • Wiring: /shell-event route registers for a macOS host run (the widened gate, §7.7.3); host_exec produces exec events attributed to the right SessionID; Build/Run events reach observeShellEvent through HostShellEventListener with a synthetic origin id; the listener refuses a wrong bearer token.
    • Degradation: bundled binary absent → /bin/zsh -lc, command succeeds, session marked shellObservability: degraded; nucleic.host.legacyShell on → every site back on zsh; shell: "zsh" per call honored; listener down → command unaffected, events dropped.
    • Bundle integrity (CI, not unit): the built .app contains Contents/MacOS/nash, it is arm64 (lipo -archs), and it is signed with the app's identity under the hardened runtime (codesign -dv --verbose=4).

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 narOS v1 with the divert baked (naros-agent, NAROS.md N2 — supersedes the v8 image); Project.swift pin flip; custom-image probe/degrade incl. the narOS fast path; legacyShell rollback lever Week of dogfood sessions, zero fallback-event regressions unresolved (these double as narOS v1 ship gates)
M4 — Linux VM spool drain op + host poll (nash forcing is handled by narOS N4's baked divert — nash via naros-tier-vm on a narOS guest rootfs — so no agent.c exec swap or hand-installed provisioner steps) 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
M5.5 — macOS host (gated) LoginShellEnv snapshot + injection (ships and bakes first, on its own setting); nash embedded/signed in the app bundle; hostShellArgv; runOnHost + host monitor + Build/Run flipped; widened /shell-event route gate + HostShellEventListener; capture=meta default; shell:"zsh" escape hatch and nucleic.host.legacyShell Differential replay green (§10.6 — semantic diffs, not just parse failures); LoginShellEnv a full dogfood week with zero command not found regressions; feed shows exec events for host_exec; zero unresolved fallbacks. TerminalPanel explicitly not in this gate (§7.7.5)
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 Superseded by narOS: one os/VERSION constant + a CI assertion that the Project.swift pin matches a published stable naros-agent tag (NAROS §8)
Agent turns nash off via its own environment (NUCLEIC_NASH_DISABLE=1, NUCLEIC_REAL_BASH=…, NUCLEIC_SHELL_CAPTURE=off) Closed (§4.3, decided 2026-07-27 — previously "accepted"). Levers move to the root-owned /etc/nucleic/nash.conf; the env forms are ignored and emit a policy event, so the attempt is a feed row instead of a silence. require_observation also makes a stripped transport env spool rather than run silent
Agent bypasses nash another way (exec /usr/bin/bash.real, python3 -c, sudo over the policy file) Accepted (non-goal §1); the exec event recording the bypass is itself the observability win; .real paths can be policy-flagged in feed classification. Genuinely closing this means moving the boundary outside the sandbox (drop passwordless sudo, root-only .real binaries, runtime exec auditing) — a separate decision with real cost to agent workflows
macOS zsh-isms M5 gated; per-op shell: escape hatch; auto-fallback to zsh on parse failure
Host commands silently change meaning under nash (parse fine, differ in word splitting / no-match globbing) — the one new failure mode §7.7 introduces, invisible to fallback telemetry M5.5's gate is a differential replay diffing output and file-system delta vs zsh (§10.6), not a fallback-rate metric; host_exec's tool description states the shell is bash-compatible so authored commands shift; per-call shell:"zsh"; nucleic.host.legacyShell
Host env regressionnash -l doesn't source the user's zsh rc files, so a naive flip loses Homebrew/version-manager/toolchain env and commands fail command not found LoginShellEnv snapshot lands and bakes on its own setting before the argv flip (§7.7.2); PATH already survives via the existing LoginShellPATH.augment chokepoint; explicit spec.env and Managed Git pins always win over the snapshot; degradation to zsh -lc is one setting away
Capturing the user's own files — host taps read a real home directory, not a sandbox Host defaults to capture=meta: no redirect/pipe/cmdsub previews unless explicitly opted in (§7.7.4); HostExecPolicy.secretFileBlocked already refuses dotenv reads pre-shell; host-originated events tagged so §9.7 phone retention is separately configurable; §5.4 caps + in-process redaction still apply when tap is on
Terminal panel transcribes a human's keystrokes, and nash has no interactive layer today Panel is last, default off, own setting; capture=off there even when nash is enabled; gated on a nash-interactive build reaching zsh parity for line editing/history/job control — and if that isn't worth building, the pane stays on $SHELL permanently (§7.7.5)
Bundled nash unsigned / stripped by a repackaged build Signed with the app's Team ID under the hardened runtime and notarized with the bundle; CI asserts presence, universality, and signature (§10.6); a missing or unexecutable binary degrades to /bin/zsh -lc rather than failing the command