mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
# Bugs

## Security / Reliability Audit Findings

### Medium: Alias trailing-blank expansion does not recheck the next command word

- Location: `src/parser/program.rs`, `rewrite_command_name_alias`.
- Impact: POSIX alias expansion rechecks the following command word when an alias value ends in a blank. `mxsh` suppresses alias expansion after rewriting, so chained aliases fail.
- Reproducer: `alias foo="bar "` newline `alias bar="echo ok"` newline `foo`.
- Expected: The script should print `ok`.

### Medium: `shift` treats invalid counts as `1`

- Location: `src/shell/builtins.rs`, `builtin_shift`.
- Impact: `shift x` and `shift -1` parse failure falls back to the default count of 1, mutating positional parameters and returning success instead of rejecting the operand.
- Reproducer: `set -- a b; shift x; echo status=$? args=$*`.
- Reproducer: `set -- a b; shift -1; echo status=$? args=$*`.
- Expected: Invalid or negative counts should return nonzero and leave positional parameters unchanged.

### Medium: `cd` always resolves physical paths and loses logical `PWD`

- Location: `src/shell/builtins.rs`, `builtin_cd`; `src/shell/builtins.rs`, `builtin_pwd`.
- Impact: The default `cd` mode is logical, but `mxsh` canonicalizes every successful target with `fs::canonicalize` and stores the physical path in `PWD`. Scripts running inside symlinked worktrees, release directories, or bind-style layouts observe a different location than the path they selected and can compute wrong relative paths.
- Reproducer: `d=$(mktemp -d); mkdir -p "$d/real/sub"; ln -s "$d/real" "$d/link"; cd "$d/link"; cd sub/..; pwd`.
- Expected: The final `pwd` should print `$d/link`, not `$d/real`.

### Medium: Redirection targets do not perform tilde expansion

- Location: `src/shell/redirects.rs`, `apply_one_redirect`.
- Impact: Redirection operands are expanded with `expand_word_nosplit`, but the result is used as a path without tilde expansion. Common redirects such as `>~/log` fail unless a literal `~` directory exists.
- Reproducer: `HOME=$(mktemp -d); echo hi >~/out`.
- Expected: The target should be `$HOME/out`, matching normal shell redirection expansion rules.

### Medium: Arithmetic variables are parsed only as integer literals

- Location: `src/shell/arithm.rs`, `eval_arithm`.
- Impact: In shell arithmetic, a variable's value can itself be an arithmetic expression. `mxsh` parses variable values directly as `i64`, so values like `1+2` fail.
- Reproducer: `X=1+2; echo $((X)); echo after`.
- Expected: The arithmetic expansion should print `3` and continue.

### Medium: Arithmetic leading-zero constants are parsed as decimal

- Location: `src/parser/arithm.rs`, `ArithmParser::literal`.
- Impact: Shell arithmetic constants follow C-style numeric bases, so a leading `0` denotes octal. The parser comment says octal is supported, but the implementation parses non-hex literals with decimal `str::parse`. Mode and bit-mask calculations using leading-zero constants are silently wrong, and invalid octal literals are accepted.
- Reproducer: `echo $((010))`.
- Reproducer: `echo $((08)); echo after`.
- Expected: The first command should print `8`; the second should report an arithmetic error for an invalid octal constant.

### Medium: Arithmetic expansion does not perform shell expansions inside the expression

- Location: `src/parser/word.rs`, `expect_word_arithmetic`; `src/shell/expand.rs`, arithmetic expansion handling; `src/shell/arithm.rs`, `eval_arithm`.
- Impact: POSIX arithmetic expansion first expands parameter expansions, command substitutions, and quote removals in the arithmetic text. `mxsh` reads raw text until `))` and sends it directly to the arithmetic parser. Expressions containing `${...}` defaults or `$(...)` command substitutions fail to parse, and command substitutions can be mistaken for the arithmetic terminator.
- Reproducer: `unset X; echo $(( ${X:-2} + 3 )); echo after`.
- Reproducer: `echo $(( $(printf 2) + 3 )); echo after`.
- Expected: Both examples should print `5` and continue.

### Medium: Arithmetic `&&` and `||` evaluate side-effecting right-hand sides

- Location: `src/shell/arithm.rs`, `eval_arithm` and `eval_arithm_binop`.
- Impact: Arithmetic logical operators should not apply side effects from the right-hand expression when the left-hand expression determines the result. `mxsh` evaluates both operands before dispatching the operator, so guarded assignments still mutate shell variables.
- Reproducer: `X=0; echo $((0 && (X=1))); echo "X=$X"`.
- Reproducer: `X=0; echo $((1 || (X=2))); echo "X=$X"`.
- Expected: Both scripts should leave `X=0`.

### Medium: `${parameter:=word}` assigns to positional parameters instead of failing

- Location: `src/shell/expand.rs`, `expand_parameter` handling for `ParameterOp::Equal`; `src/shell/state.rs`, `env_set` versus special-parameter lookup in `get_parameter_value`.
- Impact: POSIX shells reject assignment through `${1:=word}`, `${2:=word}`, and other positional or special parameters. `mxsh` stores a normal variable named `1`, `2`, etc., returns the default value once, and continues. Scripts that rely on this as a hard expansion error can run with a value that is not actually installed in the positional parameter frame.
- Reproducer: `set -- a; echo one=${2:=x}; echo two=${2-unset}; set | grep '^2='`.
- Expected: The shell should report that `$2` cannot be assigned this way and stop the non-interactive script before `echo one=...` runs.

### Medium: `${#}` and `${#@}` mishandle special parameters

- Location: `src/parser/word.rs`, `expect_parameter_expression`; `src/shell/expand.rs`, `expand_parameter` and `get_parameter_value`.
- Impact: `${#}` should expand the special parameter `$#`, and `${#@}` / `${#*}` should report the number of positional parameters. `mxsh` treats a leading `#` as the length operator too early: `${#}` is rejected as a bad substitution, while `${#@}` and `${#*}` compute the string length of the joined positional parameters. Argument-count checks can fail or take the wrong branch.
- Reproducer: `set -- a b; echo ${#}`.
- Reproducer: `set -- ab cd; echo "${#@} ${#*}"`.
- Expected: The first command should print `2`; the second should print `2 2`.

### Medium: `cd` bypasses readonly `PWD` / `OLDPWD`

- Location: `src/shell/builtins.rs`, `builtin_cd`; `src/shell/state.rs`, `env_set_internal`.
- Impact: `cd` writes `PWD` with `env_set_internal`, bypassing the readonly attribute, and ignores `env_set` failure for `OLDPWD`. A script that marked either variable readonly can have shell-visible location state silently changed anyway.
- Reproducer: `pwd; readonly PWD; cd /tmp; echo "status=$? pwd=$PWD"; /bin/pwd`.
- Expected: `cd` may still change the process directory, but the readonly `PWD` variable should not be overwritten; the shell should diagnose the readonly update instead of silently changing it.

### Medium: Bare `--` is rejected instead of reading standard input

- Location: `src/args.rs`, `parse_option_args_with_schema` and `process_args`; `src/shell/driver.rs`, `run_non_interactive`.
- Impact: `mxsh --` with no script operand is treated as a usage error. Standard `sh --` stops option parsing and then reads commands from standard input, so wrappers that defensively insert `--` before a stdin script fail under `mxsh`.
- Reproducer: `printf 'echo ok\n' | mxsh --`.
- Expected: The shell should read stdin and print `ok`, not exit with usage status 1.

### Medium: Shortest suffix removal misses the empty suffix

- Location: `src/shell/expand.rs`, `strip_suffix`.
- Impact: `${var%pattern}` should consider the empty suffix as the shortest possible match. For patterns such as `*`, `mxsh` removes the last character instead of removing nothing.
- Reproducer: `X=abc; echo "${X%*}"`.
- Expected: The result should be `abc`, not `ab`.

### Medium: Quoted pattern-removal metacharacters are treated as active patterns

- Location: `src/shell/expand.rs`, `expand_parameter`, `strip_prefix`, and `strip_suffix`.
- Impact: Quotes inside `${var%word}`, `${var%%word}`, `${var#word}`, and `${var##word}` should be able to make pattern metacharacters literal. `mxsh` expands the pattern word to a plain string and loses quote information, so quoted `*`, `?`, and bracket expressions still match as wildcards. User-controlled suffix or prefix strings can remove more text than intended.
- Reproducer: `X=abc; printf '<%s>\n' "${X%"*c"}" "${X%%"*c"}"`.
- Expected: Both expansions should produce `<abc>` because the `*` was quoted.

### Medium: Interactive parameter expansion errors exit the shell

- Location: `src/shell/expand.rs`, `expand_parameter` handling for `OPT_NOUNSET` and `ParameterOp::QMark`.
- Impact: Expansion errors such as `${name?message}` and `set -u; echo "$missing"` should abort non-interactive scripts, but interactive shells should report the error and continue reading commands. `mxsh` calls `set_exit_code` unconditionally in these paths, so a typo at an interactive prompt can terminate the whole shell session.
- Reproducer: Start an interactive shell and run `echo "${MISSING?boom}"`.
- Reproducer: Start an interactive shell and run `set -u; echo "$MISSING"`.
- Expected: The interactive shell should print the expansion diagnostic, set the command status nonzero, and return to the prompt instead of exiting.

### Medium: Here-doc backslash-newline continuations are not removed

- Location: `src/parser/program.rs`, `resolve_here_documents`; `src/parser/word.rs`, `here_document_line`; `src/shell/redirects.rs`, here-document expansion in `apply_one_redirect`.
- Impact: In an expandable here-document, `\newline` should be treated like a line continuation before the text is supplied to the command. `mxsh` stores here-doc bodies as independent physical lines, so a trailing backslash is preserved and an extra newline is inserted. Generated config files, scripts, and protocol payloads that rely on continued here-doc lines are corrupted.
- Reproducer: `printf 'cat <<EOF\nfoo\\\nbar\nEOF\n' | mxsh | od -An -tx1`.
- Expected: The bytes should be `66 6f 6f 62 61 72 0a` (`foobar\n`), matching `/bin/sh`, not `66 6f 6f 5c 0a 62 61 72 0a` (`foo\\\nbar\n`).

### Medium: `ulimit -f` treats POSIX block counts as raw bytes

- Location: `src/shell/builtins.rs`, `builtin_ulimit`; `src/sys/mod.rs`, resource-limit helpers.
- Impact: POSIX `ulimit -f` values are measured in 512-byte blocks. `mxsh` passes the raw argument directly to `RLIMIT_FSIZE` and prints the raw kernel byte value, so scripts set a file-size limit 512 times smaller than requested. Child commands can fail or silently truncate output under limits that should have allowed the write.
- Reproducer: `d=$(mktemp -d); cd "$d"; ulimit -f 1; /usr/bin/printf xx >out; wc -c <out`.
- Expected: The write should succeed and `wc` should report `2`, because `ulimit -f 1` means a 512-byte limit.

### Medium: Failed `exec` temporarily changes the host process cwd

- Location: `src/sys/unix_exec.rs`, `exec_replace`; `src/shell/builtins.rs`, `builtin_exec`.
- Impact: The Unix `exec` replacement path saves the current process cwd, calls `chdir(state.cwd)`, tries `execve` candidates, and restores the cwd only after failure. In an embedded, multi-threaded host, every other thread observes the temporary cwd change while the failed `exec` is searching PATH; if restoration fails, the host process is left in the shell cwd. The ordinary spawn path uses a `posix_spawn` chdir file action and does not expose this process-global race.
- Reproducer: In an embedded Unix process with another thread doing relative filesystem work, set the shell current directory to a different directory and run `exec definitely-not-present`.
- Expected: A failed `exec` attempt should not mutate process cwd visible to other host threads; command lookup should use absolute/openat-style candidates, or embedded hosts should have an explicit way to forbid process-global `exec`.

### Medium: Failed `exec` temporarily rewires host process file descriptors and signal dispositions

- Location: `src/sys/unix_exec.rs`, `apply_child_fd_plan`, `signal_plan_guard`, and `exec_replace_command`; `src/shell/builtins.rs`, `builtin_exec`.
- Impact: Before `execve`, the replacement path applies child stdio, inherited-fd, close-fd, CLOEXEC, and signal-disposition plans directly to the host process, then restores them if `execve` fails. In an embedded, multi-threaded host, unrelated threads can briefly write to the shell's redirected stdout, see fd 0/1/2 closed or replaced, inherit altered CLOEXEC flags, or have process signals ignored/defaulted. A script can trigger the failure after all fd changes are applied with an executable file whose interpreter is missing.
- Reproducer: In an embedded Unix process, have one thread repeatedly write to fd 1 while a shell session runs `printf '#!/no/such/interpreter\n' >bad; chmod +x bad; exec ./bad >out`.
- Expected: A failed replacement attempt should not expose child fd or signal plans to unrelated host threads. Embedded hosts need a way to reject true process replacement or run it in a dedicated subprocess instead of mutating process-global state in place.

### Medium: Process-isolated `umask` and `ulimit` still mutate host process globals while running

- Location: `src/shell/builtins.rs`, `builtin_umask` / `builtin_ulimit`; `src/shell/state.rs`, `ProcessGlobalGuard`; `src/sys/mod.rs`, `UnixRuntime::set_umask` / `set_resource_limit`.
- Impact: Process-isolated execution captures and restores process-global state on exit, but the builtin still changes the real process umask or resource limits while the isolated command is running. In an embedded, multi-threaded host, unrelated threads can create files with the shell's temporary umask or hit the shell's temporary resource limit before the guard restores the old value.
- Reproducer: In an embedding process, run shell code such as `$(umask 077; sleep 1)` or `(ulimit -n 32; sleep 1)` while another host thread creates files or opens descriptors.
- Expected: Process-isolated shell code should not expose temporary umask or rlimit changes to unrelated host threads; hosts need either true subprocess isolation for these builtins or an explicit policy to reject them in embedded isolated contexts.

### Medium: CLI auto-interactive detection ignores the configured shell stdin fd

- Location: `src/args.rs`, `process_args`; `src/frontend.rs`, `run_cli_with_session`; `src/embed.rs`, `ShellBuilder::stdio`.
- Impact: `process_args` decides whether an argument-less CLI run is interactive by calling `std::io::stdin().is_terminal()` on the process-global fd 0. Embedded callers can configure the shell to read from a different fd, so `Shell::run_cli(["mxsh"])` can source interactive startup files, enable prompts, or choose non-interactive behavior based on the host process stdin rather than the shell session's stdin.
- Reproducer: In an embedding process whose fd 0 is a terminal, configure `ShellBuilder::stdio` with a pipe for shell stdin and captured stdout/stderr, then call `run_cli` with only `argv[0]`.
- Expected: Auto-interactive mode should be based on the configured shell stdin fd, or embedders should have an explicit way to provide the interactive decision independently of process fd 0.

### Medium: `getopts` state is exposed through user variables and can be made readonly

- Location: `src/shell/builtins.rs`, `builtin_getopts`; `src/shell/state.rs`, `env_set` / `env_unset`.
- Impact: `getopts` stores its internal cursor in ordinary shell variables named `__MXSH_GETOPTS_CURSOR` and `__MXSH_GETOPTS_INDEX`, and it ignores failures when updating them. A script or inherited environment can read, set, export, unset, or mark these implementation variables readonly. Once the cursor variable is readonly, repeated `getopts` calls can keep returning the same option with the same `OPTIND`, causing option loops to spin or parse the wrong operands.
- Reproducer: `readonly __MXSH_GETOPTS_CURSOR=1; set -- -ab; getopts ab o; printf '1:%s:%s\n' "$o" "$OPTIND"; getopts ab o; printf '2:%s:%s\n' "$o" "$OPTIND"`.
- Expected: The second call should report `b` and advance `OPTIND` to 2, matching `/bin/sh`; implementation cursor state should not be stored in user-visible variables, and update failures should not be ignored.

### Medium: Quoted tildes in assignment values are expanded after quote removal

- Location: `src/shell/redirects.rs`, `expand_assignment_values`; `src/shell/expand.rs`, `expand_tilde_assignment`; `src/shell/builtins.rs`, `run_attribute_builtin`.
- Impact: Assignment values are expanded to plain strings and then passed to tilde expansion, losing the quote information that should suppress tilde recognition. `X="~"` and declaration-style operands such as `export X="~"` store `$HOME` instead of a literal tilde. Scripts that intentionally quote config values, templates, or remote paths can silently write host-specific absolute paths.
- Reproducer: `HOME=$(mktemp -d); X="~"; printf '<%s>\n' "$X"`.
- Expected: The output should be `<~>`, not the temporary home directory; tilde expansion should only apply to unquoted tilde prefixes in assignment words.

### Low: `${#var}` reports UTF-8 bytes instead of characters

- Location: `src/shell/expand.rs`, `expand_parameter`.
- Impact: Parameter length uses `String::len()`, so multibyte characters are counted as bytes. In a UTF-8 locale, POSIX shells report character length.
- Reproducer: `X=é; echo ${#X}`.
- Expected: The result should be `1`, not `2`.

### Low: Field splitting keeps a trailing empty field for mixed whitespace and non-whitespace IFS

- Location: `src/shell/expand.rs`, `split_fields`.
- Impact: When `IFS` contains both whitespace and non-whitespace delimiters, a trailing non-whitespace delimiter incorrectly produces a final empty argument.
- Reproducer: `IFS=", "; X=" a, b ,"; set -- $X; printf "<%s>\n" "$@"`.
- Expected: The output should contain `<a>` and `<b>` only.

### Low: Bare `set -`, `set +`, and `command` are rejected

- Location: `src/shell/builtins.rs`, `builtin_set`; `src/shell/builtins.rs`, `builtin_command`.
- Impact: POSIX shells accept bare `set -`, bare `set +`, and `command` with no command name as successful no-op forms. `mxsh` prints usage and returns 1.
- Reproducer: `set -; echo status=$?`.
- Reproducer: `set +; echo status=$?`.
- Reproducer: `command; echo status=$?`.
- Expected: Each command should return status 0.

### Low: `command --` is not accepted

- Location: `src/shell/builtins.rs`, `builtin_command`.
- Impact: `command -- utility ...` is accepted by `/bin/sh`, but `mxsh` treats `--` as an unknown option.
- Reproducer: `command -- echo ok; echo status=$?`.
- Expected: The command should print `ok` and return 0.

### Low: `umask` does not support symbolic modes

- Location: `src/shell/builtins.rs`, `builtin_umask`.
- Impact: POSIX `umask` accepts symbolic mode strings. `mxsh` only parses octal values, so portable scripts using symbolic masks fail.
- Reproducer: `old=$(umask); umask u=rwx,go=rx; echo status=$?; umask "$old"`.
- Expected: The symbolic mode should be accepted and return status 0.


### Medium: `pwd` ignores options and cannot report physical paths

- Location: `src/shell/builtins.rs`, `builtin_pwd`; `src/shell/builtins.rs`, `run_builtin` handling for `Builtin::Pwd`.
- Impact: `pwd -P` is the portable way to resolve the physical directory after logical `cd` through symlinks, and invalid options should fail. `mxsh` discards every `pwd` operand, so `pwd -P` prints the logical `PWD`, `pwd -L` is indistinguishable from default output, and invalid options such as `pwd -Z` return success. Scripts that canonicalize paths before comparing, locking, or passing them to other tools can use the symlink path by mistake.
- Reproducer: `d=$(mktemp -d); mkdir -p "$d/real"; ln -s "$d/real" "$d/link"; cd "$d/link"; pwd -P`.
- Reproducer: `pwd -Z; echo status=$?`.
- Expected: `pwd -P` should print the physical `$d/real` path, and `pwd -Z` should diagnose an invalid option and return nonzero.

### Medium: Bare `set -` leaves xtrace and verbose mode enabled

- Location: `src/shell/builtins.rs`, `builtin_set`.
- Impact: POSIX `set -` is the historical no-option form that turns off `-x` and `-v` while leaving other options alone. `mxsh` accepts the command as a no-op, so scripts that use `set -` to stop tracing continue to emit xtrace or verbose output. That can leak commands, expanded arguments, and secrets after a script attempted to quiet tracing.
- Reproducer: `set -x; set -; echo secret`.
- Reproducer: `set -v; set -; echo secret`.
- Expected: The `echo secret` command should execute without xtrace or verbose echoing; other flags such as `-e` should remain unchanged.

### Medium: Command substitution and `read` corrupt byte-oriented shell data

- Location: `src/sys/fd.rs`, `read_line_fd_inner` and `read_to_string_with_limit`; `src/shell/expand.rs`, `run_command_substitution`; `src/shell/read.rs`, `read_stdin_input`.
- Impact: Shell data streams are byte-oriented, but `mxsh` decodes fd input with `String::from_utf8_lossy`. Non-UTF-8 bytes read by command substitution or `read` are replaced with UTF-8 replacement characters, corrupting payloads and filenames. Command substitution also preserves NUL bytes in shell strings, which later makes external command spawning fail because argv/env entries cannot contain NUL.
- Reproducer: `printf %s "$(/usr/bin/printf '\300')" | od -An -t x1`.
- Reproducer: `/usr/bin/printf '\300\n' | mxsh -c 'read x; printf %s "$x"' | od -An -t x1`.
- Reproducer: `x=$(/usr/bin/printf 'a\000b'); /usr/bin/printf '<%s>\n' "$x"; echo status=$?`.
- Expected: The first two examples should preserve byte `c0`, matching `/bin/sh`, instead of emitting `ef bf bd`. The NUL example should not store an unspawnable argv string; common shells drop the NUL and print `<ab>` with status 0.

### Medium: `getopts` ignores readonly failures for the destination variable

- Location: `src/shell/builtins.rs`, `builtin_getopts`.
- Impact: `getopts` writes the option name, `OPTIND`, and `OPTARG` with `env_set` / `env_unset` but ignores failures. If the destination variable is readonly, `getopts` can return success while leaving a stale value in place. Typical `while getopts ...; do case "$opt" ...` loops can execute the wrong option branch instead of reporting the readonly assignment failure.
- Reproducer: `opt=b; readonly opt; set -- -a; while getopts ab opt; do echo "opt=$opt"; done; echo after`.
- Expected: The readonly assignment should be diagnosed and the loop body should not run with stale `opt=b`.

### Medium: POSIX bracket character classes are ignored in internal patterns

- Location: `src/shell/expand.rs`, `pattern_match_inner` and `match_char_bracket_class`.
- Impact: Pathname expansion is delegated to libc `glob`, but `case` patterns and parameter-removal patterns use the shell's internal matcher. That matcher handles simple ranges but not POSIX bracket character classes such as `[[:digit:]]`, `[[:alpha:]]`, or `[[:space:]]`. Scripts that classify input or trim numeric suffixes with portable bracket classes take fallback branches or leave text untrimmed.
- Reproducer: `case 5 in [[:digit:]]) echo digit;; *) echo no;; esac`.
- Reproducer: `x=abc123; echo "${x%%[[:digit:]]*}"`.
- Expected: The first script should print `digit`, and the second should print `abc`.

### Medium: Sourced files lose `return` status when anything follows the return

- Location: `src/shell/run.rs`, `run_reader_with_context`; `src/shell/builtins.rs`, `builtin_dot`.
- Impact: `return` from a dot script sets `ControlFlow::Return`, but the source reader only stops early for `exit_code`. It keeps parsing later lines in the sourced file, and each skipped parsed program overwrites the status with 0. A library that ends `return 7` before a trailing blank, comment, or dead command therefore reports success to its caller, bypassing error handling in scripts that source helper files.
- Reproducer: `printf 'return 7\n\n' >lib.sh; . ./lib.sh; echo status=$?`.
- Reproducer: `printf 'return 7\necho unreachable\n' >lib.sh; . ./lib.sh; echo status=$?`.
- Expected: Both scripts should print `status=7`; no later line in the sourced file should be parsed as a successful command after `return`.

### Medium: Command substitution output is capped at 1 MiB and aborts scripts

- Location: `src/shell/expand.rs`, `run_command_substitution`; `src/sys/fd.rs`, `read_to_string_with_limit`.
- Impact: Command substitution reads through a hard-coded `MAX_COMMAND_SUBSTITUTION_BYTES` limit of 1 MiB. When output exceeds that size, expansion records an error and non-interactive scripts exit instead of assigning the substitution result. Scripts that legitimately capture generated JSON, manifests, encoded payloads, or tool output larger than 1 MiB fail under `mxsh` even though ordinary POSIX shells allow the substitution subject to available memory.
- Reproducer: `x=$(/usr/bin/yes x | /usr/bin/head -c 1048577); echo "status=$? len=${#x}"; echo after`.
- Expected: The assignment should succeed and the script should continue, with `len=1048577` before any trailing-newline trimming effects.

### Medium: `$()` syntax errors abort the outer script instead of staying inside the substitution

- Location: `src/parser/word.rs`, `expect_word_command`; `src/parser/program.rs`, command-substitution body parsing.
- Impact: The parser fully validates `$()` bodies while parsing the outer script. A syntax error inside the substitution therefore aborts the entire script before commands around it can run. Other shell forms, including backquotes in `mxsh` and `$()` in `/bin/sh`/`bash`, report the substitution syntax error when that word is expanded and continue the surrounding command flow. Generated scripts that probe optional command output can stop at parse time instead of handling the substitution failure.
- Reproducer: `echo before; echo $(if); echo after; echo status=$?`.
- Expected: The shell should print `before`, report a command-substitution syntax error, then continue with `after` and the surrounding command status, rather than exiting with a top-level parse error before `before` runs.

### Medium: Readonly `OPTIND` can make `getopts` repeat the same option forever

- Location: `src/shell/builtins.rs`, `builtin_getopts`.
- Impact: `getopts` updates `OPTIND`, `OPTARG`, and the destination variable with `env_set` / `env_unset` but ignores failures. If `OPTIND` is readonly, the internal cursor can reset against the stale public value on each call, so loops keep seeing the same option and never advance to later operands. This can spin option parsers or repeatedly execute the wrong option branch.
- Reproducer: `readonly OPTIND=1; set -- -a -b; getopts ab opt; printf '1:%s:%s\n' "$opt" "$OPTIND"; getopts ab opt; printf '2:%s:%s\n' "$opt" "$OPTIND"`.
- Expected: The readonly assignment failure should be diagnosed and should not let parsing continue with stale state; a second successful call must not return `a` again for the same `-a` operand.

### Medium: Assignment tilde expansion misses colon-separated prefixes after the first segment

- Location: `src/shell/expand.rs`, `assignment_tilde_allowed`, `declaration_assignment_tilde_allowed`, and `expand_tilde_assignment`.
- Impact: POSIX assignment tilde expansion applies after `=` and after unquoted `:` separators in assignment values. `mxsh` has an `expand_tilde_assignment` helper that handles colon-separated segments, but it only calls it when the whole value starts with `~`. Values such as `PATH=/bin:~/bin` or declaration operands such as `export X=foo:~/bar` keep the literal tilde, so child command lookup paths and generated configuration values can point to nonexistent locations.
- Reproducer: `HOME=$(mktemp -d); X=foo:~/bar; printf '<%s>\n' "$X"`.
- Reproducer: `HOME=$(mktemp -d); export X=foo:~/bar; printf '<%s>\n' "$X"`.
- Expected: Both examples should expand the second segment to `foo:$HOME/bar`.

### Medium: Logical `cd` validates symlink paths before logical `..` cleanup

- Location: `src/shell/builtins.rs`, `builtin_cd` logical-mode branch.
- Impact: In logical mode, `cd` should normalize `.` and `..` against the logical `PWD` path. `mxsh` first checks `fs::metadata(&target)` on the unresolved operand path, so a symlink followed by `..` is validated through the symlink's physical parent before the logical path is normalized. Scripts that intentionally navigate symlinked release directories or worktree aliases can get a false `not a directory` even when the logical destination exists.
- Reproducer: `base=$(mktemp -d); other=$(mktemp -d); mkdir -p "$other/real" "$base/target"; ln -s "$other/real" "$base/link"; cd "$base"; cd link/../target`.
- Expected: The final `cd` should succeed and leave `PWD=$base/target`, matching logical `/bin/sh` behavior.

### Medium: `export`, `readonly`, and `unset` reject the `--` option delimiter

- Location: `src/shell/builtins.rs`, `run_attribute_builtin` and `builtin_unset`.
- Impact: These utilities follow option syntax and should accept `--` before operands. `mxsh` treats `export -- X` as an unknown option and `unset -- X` as an invalid identifier. Because `export`, `readonly`, and `unset` are special builtins, this ordinary delimiter usage can abort a non-interactive script before cleanup or fallback code runs.
- Reproducer: `X=1; export -- X; echo export=$?`.
- Reproducer: `X=1; unset -- X; echo "X=${X-unset}"`.
- Expected: `export -- X` should mark `X` for export and continue; `unset -- X` should unset `X` and print `X=unset`.

### Medium: `$!` names the background machine wrapper instead of the asynchronous command

- Location: `src/shell/machine.rs`, `spawn_background_job`; `src/shell/jobs.rs`, `run_background`; `src/shell/expand.rs`, `get_parameter_value` for `$!`.
- Impact: Background jobs are executed by an `mxsh --machine` wrapper, and the parent records the wrapper's display pid as the last background pid. For a simple external asynchronous command, scripts expect `$!` to identify that command's process. Instead, process inspection, pid-file handoff, and targeted signaling see the wrapper process. Signal forwarding mitigates common `kill $!` cases, but tools such as `ps -p "$!"`, supervisors, and scripts that expect the child executable's pid observe `mxsh` rather than the command they started.
- Reproducer: `sleep 30 & ps -p "$!" -o comm=`.
- Expected: For a simple external background command, `$!` should identify the `sleep` process, not an implementation wrapper.

### Medium: `set -C` rejects existing non-regular redirection targets

- Location: `src/shell/redirects.rs`, `apply_one_redirect` handling for `IoRedirectOp::Great`.
- Impact: Noclobber mode should protect existing regular files from `>` truncation, but it should not make ordinary non-clobbering targets such as `/dev/null` unusable. `mxsh` implements `set -C` by adding `O_EXCL` to every `>` open, so redirects to existing character devices, FIFOs, and other non-regular files fail with `EEXIST`. Scripts that enable noclobber defensively can break routine output suppression and device/fifo handoff paths.
- Reproducer: `set -C; echo ok >/dev/null; echo status=$?`.
- Expected: The redirect to `/dev/null` should succeed and the final status should be 0. Existing regular files should still be protected unless `>|` is used.

### Medium: Startup `-` is rejected instead of reading standard input

- Location: `src/args.rs`, `parse_option_args_with_schema` and `process_args`.
- Impact: Standard `sh -` treats the bare `-` startup operand as an option terminator / stdin script request. `mxsh` treats a one-character `-` as a usage error before it can choose stdin. Wrappers and tools that pass `-` to force stdin execution fail before any script text is read.
- Reproducer: `printf 'echo ok\n' | mxsh -`.
- Expected: The shell should read commands from standard input and print `ok`, matching `/bin/sh -`.

### Medium: Arithmetic comma and increment/decrement operators are unsupported

- Location: `src/parser/arithm.rs`, arithmetic expression grammar; `src/shell/arithm.rs`, arithmetic evaluation.
- Impact: Common shell arithmetic supports sequencing with the comma operator and prefix/postfix increment and decrement for counters. `mxsh` rejects these expressions as parse errors, aborting scripts that use compact loop counters, option parsers, or arithmetic side-effect sequencing.
- Reproducer: `X=0; echo $((X=1, X=2)); echo "X=$X"`.
- Reproducer: `X=1; echo $((X++)); echo "X=$X"`.
- Expected: The first script should print `2` and leave `X=2`; the second should print `1` and leave `X=2`.

### Medium: EOF before a here-document delimiter aborts the command

- Location: `src/parser/program.rs`, `resolve_here_documents`.
- Impact: When EOF arrives before a here-document delimiter, POSIX-style shells warn but use the accumulated here-document body and execute the command. `mxsh` records this as a fatal parse error and skips the command entirely. Generated or streamed scripts that are accepted by `/bin/sh` can lose the command output and return syntax status 2 under `mxsh`.
- Reproducer: `printf 'cat <<EOF\nhi\n' | mxsh`.
- Expected: The shell should warn about the missing delimiter but still run `cat` with `hi\n` as the here-document body, matching `/bin/sh`.