# nash — the Nucleic Agent Shell

**Status: M0–M2 complete; M3's image + host work is implemented via narOS N2
([NAROS.md](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
`setenv`s `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 `parseShellBatch` → `observeShellEvent` 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 `exec`ing
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 → `ConflictCoordinator` →
`AppStore.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](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](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](HOST_EXEC_CONCURRENCY.md) (the other consumer of host command
structure; §7.7 feeds it), [VSOCK_CONTROL_PLANE.md](VSOCK_CONTROL_PLANE.md) (transport this rides on),
[CONTAINER_ISOLATION.md](CONTAINER_ISOLATION.md), [LINUX_VM.md](LINUX_VM.md),
[MACOS_VM.md](MACOS_VM.md), [OBSERVABILITY_AND_TESTING.md](OBSERVABILITY_AND_TESTING.md)
(redaction posture), [LOCKING.md](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-inject** — `nash -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 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**

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_container`s, 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 **N**ucleic **a**gent **sh**ell. 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_CAPTURE` — `off | 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.

```rust
// 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, `export`s 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 `source`d 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](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.5–2 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.arbitrateShellWrites` → `AppStore.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`):

```jsonc
{
  "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](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:

```dockerfile
# 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](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* `ProcessSpec`s.
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 -lc` → `nash -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](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`
  `.deb`s 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 `Task`s 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](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](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 regression** — `nash -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 |
