From da2943016836e94ed921b327748df2ca0c7bc2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Thu, 9 Apr 2026 10:55:45 +0200 Subject: [PATCH] docs(phase-1): research minimal viable sandbox Co-Authored-By: Claude Opus 4.6 --- .../01-minimal-viable-sandbox/01-RESEARCH.md | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 .planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md diff --git a/.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md b/.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md new file mode 100644 index 0000000..1c49209 --- /dev/null +++ b/.planning/phases/01-minimal-viable-sandbox/01-RESEARCH.md @@ -0,0 +1,471 @@ +# Phase 1: Minimal Viable Sandbox - Research + +**Researched:** 2026-04-09 +**Domain:** Nix derivation + bubblewrap sandboxing for Claude Code +**Confidence:** HIGH + +## Summary + +This phase produces a single Nix flake that outputs a `claudebox` command wrapping Claude Code inside a bubblewrap (bwrap) sandbox. The sandbox uses `--clearenv` to start with an empty environment, allowlists specific variables, bind-mounts only the necessary filesystem paths, and explicitly excludes all secret material. + +The host system (NixOS with Lix 2.93.3) has bubblewrap 0.11.0, which supports all required flags. Claude Code is a Node.js application (v2.1.70 on host, 2.0.51 in nixpkgs) installed as a wrapped bash script that execs node. The `comma-with-db` package from `nix-community/nix-index-database` is confirmed available and bundles its own database. NixOS has several `/etc` symlink chains that need careful handling for DNS and SSL to work inside the sandbox. + +**Primary recommendation:** Use `writeShellApplication` with `builtins.readFile` for the script body, `--clearenv` + `--setenv` for environment, tmpfs root with selective bind-mounts, and `exec` into the final claude command for clean signal handling. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- **D-01:** Forward all unknown flags to `claude`. claudebox claims only its own flags (`--yes`, `--dry-run`, `--check`) and passes everything else through. No `--` separator required. `--dangerously-skip-permissions` is always injected. +- **D-02:** Use `comma-with-db` from the `nix-community/nix-index-database` flake. Self-contained -- bundles the package index, no host dependency, no extra bind mount needed. DB updates when the flake input is bumped. +- **D-03:** Strict allowlist per SAND-03, plus a `CLAUDEBOX_EXTRA_ENV` escape hatch. Core allowlist always passes (HOME, PATH, TERM, EDITOR, LANG, LC_ALL, NIX_SSL_CERT_FILE, SSL_CERT_FILE, ANTHROPIC_API_KEY, USER, SHELL, XDG_RUNTIME_DIR). User can add extras at launch via `CLAUDEBOX_EXTRA_ENV="COLORTERM,NODE_OPTIONS"` -- their responsibility to not leak secrets. +- **D-04:** Sandbox-generated vars (TMPDIR=/tmp, etc.) are set via `--setenv`, never read from host. +- **D-05:** Generate a minimal `.gitconfig` inside the sandbox at launch time. Reads `user.name` and `user.email` from the host's git config, writes them plus `safe.directory = *` into the sandbox's `~/.gitconfig`. No host `.gitconfig` mounted. + +### Claude's Discretion +- Mount ordering strategy for CWD-under-HOME (bwrap specifics) +- Exact tmpfs layout and /dev, /proc, /tmp setup +- How `--clearenv` + `--setenv` are sequenced in the bwrap invocation +- DNS resolution mount strategy (resolv.conf and its symlink targets) +- SSL cert bundle path detection + +### Deferred Ideas (OUT OF SCOPE) +None -- discussion stayed within phase scope. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| SAND-01 | Wrapper via Nix `writeShellApplication` | Standard Stack: writeShellApplication with builtins.readFile pattern | +| SAND-02 | `--clearenv` empty environment | Verified: bwrap 0.11.0 supports `--clearenv` + `--setenv` | +| SAND-03 | Environment allowlist | Architecture: env passthrough loop pattern | +| SAND-04 | tmpfs root filesystem | Verified: `--tmpfs /` works in bwrap 0.11.0 | +| SAND-05 | CWD bind-mounted rw | Architecture: mount ordering (CWD after HOME dir creation) | +| SAND-06 | `/nix/store` read-only | Verified: `--ro-bind /nix/store /nix/store` works | +| SAND-07 | Nix daemon socket mounted | Verified: `/nix/var/nix/daemon-socket` bind works, nix can talk to daemon | +| SAND-08 | `~/.claudebox` -> `~/.claude` | Architecture: bind `~/.claudebox` as `$HOME/.claude` | +| SAND-09 | Secret paths never mounted | Architecture: negative list, verified by env check | +| SAND-10 | PATH only Nix store paths | Standard Stack: runtimeInputs wires PATH automatically | +| SAND-11 | Working /tmp, /dev, /proc | Verified: `--tmpfs /tmp --dev /dev --proc /proc` | +| SAND-12 | DNS resolution works | Pitfalls: NixOS resolv.conf is a real file (not symlink), bind-mount directly | +| SAND-13 | SSL/TLS works | Pitfalls: NixOS cert chain requires `/etc/ssl` AND `/etc/static` mounts | +| SAND-14 | Exit code passthrough | Architecture: `exec bwrap ...` pattern | +| SAND-15 | Signals via exec | Architecture: `exec` ensures no intermediate shell | +| TOOL-01 | comma available | Standard Stack: comma-with-db from nix-index-database flake | +| TOOL-02 | `nix shell` works | Verified: daemon socket + nix.conf mount enables nix commands | +| TOOL-03 | New store paths visible | Architecture: `/nix/store` must be a live bind, not snapshot | +| GIT-01 | Git works with minimal config | Architecture: generate .gitconfig at launch from host identity | +| GIT-02 | safe.directory configured | Architecture: `safe.directory = *` in generated .gitconfig | +| NIX-01 | Nix flake with default package | Standard Stack: flake.nix structure | +| NIX-02 | Runtime deps pinned via flake | Standard Stack: flake inputs pin nixpkgs + nix-index-database | +| NIX-03 | `nix run` / `nix profile install` works | Standard Stack: flake outputs packages.default | +| UX-06 | `--dangerously-skip-permissions` always passed | Architecture: injected before user args in exec | + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `writeShellApplication` | nixpkgs stable | Produce claudebox script | Shellcheck at build, `set -euo pipefail`, runtimeInputs wiring [VERIFIED: nix eval on host] | +| `bubblewrap` | 0.11.0 | Sandbox runtime | Unprivileged user-ns sandbox, all required flags confirmed [VERIFIED: `bwrap --version` on host] | +| `comma-with-db` | 2.3.3 | On-demand package runner | Bundles nix-index database, no extra mount needed [VERIFIED: `nix eval github:nix-community/nix-index-database#packages.x86_64-linux.comma-with-db.name`] | + +### Runtime Dependencies (runtimeInputs for writeShellApplication) +| Package | Purpose | Notes | +|---------|---------|-------| +| `bubblewrap` | Sandbox | 0.11.0 in nixpkgs [VERIFIED: `nix eval nixpkgs#bubblewrap.version`] | +| `coreutils` | Basic utils | env, cat, mkdir, etc. [VERIFIED: available] | +| `git` | VCS | Claude Code requires git [VERIFIED: available] | +| `curl` | HTTP | MCP + tool use [VERIFIED: works inside sandbox] | +| `jq` | JSON | Config manipulation [ASSUMED: standard nixpkgs] | +| `ripgrep` | Search | Claude Code's grep [ASSUMED: standard nixpkgs] | +| `fd` | File find | Claude Code's find [ASSUMED: standard nixpkgs] | +| `nix` | Package mgr | For `nix shell` inside sandbox [VERIFIED: daemon comms work] | +| `comma-with-db` | On-demand pkgs | From nix-index-database flake input [VERIFIED: 2.3.3] | +| `bash` | Shell | bwrap exec target [VERIFIED: available] | +| `nodejs` | Runtime | Claude Code is a Node.js app [VERIFIED: nodejs-24.13.0 in closure] | + +### Excluded (secrets) +| Package | Why Excluded | +|---------|-------------| +| `gnupg` | Secret material | +| `openssh` | Secret material | +| `age`/`agenix` | Secret material | +| `tailscale` | Infrastructure access | + +### Flake Inputs +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + nix-index-database = { + url = "github:nix-community/nix-index-database"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; +} +``` +[VERIFIED: nix-index-database uses `packages.x86_64-linux.comma-with-db` output -- the `legacyPackages` path is deprecated] + +## Architecture Patterns + +### Recommended Project Structure +``` +claudebox/ +├── flake.nix # Flake with nixpkgs + nix-index-database inputs +├── flake.lock # Pinned dependencies +├── claudebox.sh # Shell script body (read via builtins.readFile) +├── CLAUDE.md # Project docs +└── .planning/ # GSD planning artifacts +``` + +### Pattern 1: writeShellApplication with builtins.readFile +**What:** Keep the shell script in a separate `.sh` file, read it into the Nix expression. +**When to use:** Always -- gives shell syntax highlighting, independent shellcheck, easier iteration. +**Example:** +```nix +# flake.nix (simplified) +{ + outputs = { self, nixpkgs, nix-index-database, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + comma-with-db = nix-index-database.packages.${system}.comma-with-db; + in { + packages.${system}.default = pkgs.writeShellApplication { + name = "claudebox"; + runtimeInputs = [ + pkgs.bubblewrap pkgs.coreutils pkgs.git pkgs.curl + pkgs.jq pkgs.ripgrep pkgs.fd pkgs.nix + comma-with-db pkgs.bash pkgs.nodejs + ]; + text = builtins.readFile ./claudebox.sh; + }; + }; +} +``` +[VERIFIED: writeShellApplication API is stable in nixpkgs, runtimeInputs prepends to PATH] + +### Pattern 2: bwrap Invocation Structure +**What:** The core sandbox call with proper ordering. +**Mount ordering rule:** tmpfs root first, then system mounts, then HOME-level mounts, then CWD (most specific last wins). + +```bash +exec bwrap \ + --clearenv \ + # --- Sandbox-generated vars --- + --setenv HOME "$HOME" \ + --setenv USER "$USER" \ + --setenv PATH "$SANDBOX_PATH" \ + --setenv TERM "${TERM:-xterm}" \ + --setenv SHELL "/bin/bash" \ + --setenv TMPDIR /tmp \ + --setenv NIX_SSL_CERT_FILE /etc/ssl/certs/ca-certificates.crt \ + # --- Allowlisted host vars (only if set) --- + ${EDITOR:+--setenv EDITOR "$EDITOR"} \ + ${LANG:+--setenv LANG "$LANG"} \ + ${ANTHROPIC_API_KEY:+--setenv ANTHROPIC_API_KEY "$ANTHROPIC_API_KEY"} \ + # ... etc for each allowlisted var ... + # --- Filesystem: base layer --- + --tmpfs / \ + --proc /proc \ + --dev /dev \ + --tmpfs /tmp \ + # --- Filesystem: system --- + --ro-bind /nix/store /nix/store \ + --bind /nix/var/nix /nix/var/nix \ + --ro-bind /etc/resolv.conf /etc/resolv.conf \ + --ro-bind /etc/ssl /etc/ssl \ + --ro-bind /etc/static /etc/static \ + --ro-bind /etc/passwd /etc/passwd \ + --ro-bind /etc/group /etc/group \ + --ro-bind /etc/hosts /etc/hosts \ + --ro-bind /etc/nsswitch.conf /etc/nsswitch.conf \ + --ro-bind /etc/nix /etc/nix \ + --symlink /usr/bin/env /usr/bin/env \ + # --- Filesystem: user --- + --tmpfs "$HOME" \ + --bind "$HOME/.claudebox" "$HOME/.claude" \ + --bind "$CWD" "$CWD" \ + --chdir "$CWD" \ + # --- Exec --- + -- claude --dangerously-skip-permissions "$@" +``` +[VERIFIED: tested bwrap invocations on host confirm this structure works] + +### Pattern 3: Environment Allowlist with CLAUDEBOX_EXTRA_ENV +**What:** Loop over allowlisted vars, only pass those that are set. +```bash +ALLOWLIST=(HOME PATH TERM EDITOR LANG LC_ALL NIX_SSL_CERT_FILE SSL_CERT_FILE ANTHROPIC_API_KEY USER SHELL XDG_RUNTIME_DIR) + +# Build --setenv args array +SETENV_ARGS=() +for var in "${ALLOWLIST[@]}"; do + if [[ -v "$var" ]]; then + SETENV_ARGS+=(--setenv "$var" "${!var}") + fi +done + +# Handle CLAUDEBOX_EXTRA_ENV +if [[ -v CLAUDEBOX_EXTRA_ENV ]]; then + IFS=',' read -ra EXTRAS <<< "$CLAUDEBOX_EXTRA_ENV" + for var in "${EXTRAS[@]}"; do + if [[ -v "$var" ]]; then + SETENV_ARGS+=(--setenv "$var" "${!var}") + fi + done +fi +``` +[ASSUMED: bash array + indirect variable pattern is standard] + +### Pattern 4: Git Identity Generation +**What:** Read host git config, write minimal .gitconfig inside sandbox. +```bash +GIT_NAME=$(git config --global user.name 2>/dev/null || echo "Claude User") +GIT_EMAIL=$(git config --global user.email 2>/dev/null || echo "claude@localhost") + +# Create temp gitconfig for the sandbox +GITCONFIG_TMP=$(mktemp) +cat > "$GITCONFIG_TMP" < `--tmpfs $HOME` -> `--bind $CWD $CWD`. Most specific mounts go last. +**Warning signs:** CWD appears empty inside sandbox. +[ASSUMED: standard bwrap behavior -- mounts are processed left-to-right] + +### Pitfall 4: PATH Inside Sandbox +**What goes wrong:** `writeShellApplication` runtimeInputs prepends to the host PATH. But `--clearenv` clears PATH. The script needs to capture the Nix-constructed PATH before `--clearenv` wipes it, and pass it into the sandbox. +**Why it happens:** The wrapper script runs on the host with runtimeInputs PATH. bwrap `--clearenv` clears everything inside. +**How to avoid:** Capture `SANDBOX_PATH="$PATH"` at script top (this is the runtimeInputs-constructed PATH). Pass it via `--setenv PATH "$SANDBOX_PATH"` into bwrap. Remove any non-nix-store paths if paranoid. +**Warning signs:** Commands not found inside sandbox. +[VERIFIED: writeShellApplication prepends runtimeInputs to PATH; --clearenv removes it] + +### Pitfall 5: Nix Daemon Socket Needs Write Access +**What goes wrong:** `nix shell` fails to download packages because daemon socket is mounted read-only. +**Why it happens:** The Unix socket requires read-write access for nix client to talk to the daemon. +**How to avoid:** Use `--bind` (rw) not `--ro-bind` for `/nix/var/nix`. The daemon also needs to write to store paths (but those go through the daemon, not the client). +**Warning signs:** "error connecting to daemon" or permission denied on socket. +[VERIFIED: tested with `--bind /nix/var/nix /nix/var/nix` -- nix eval works] + +### Pitfall 6: /etc/passwd and /etc/group Required +**What goes wrong:** Various tools (git, nix, node) fail when they can't resolve the current user. +**Why it happens:** They call getpwuid/getgrgid which reads /etc/passwd and /etc/group. +**How to avoid:** Mount `/etc/passwd` and `/etc/group` read-only. +**Warning signs:** "I have no name!" prompt, git errors about user identity. +[ASSUMED: standard Unix behavior, confirmed by testing that bwrap shows uid 65534 (nobody) without these mounts] + +### Pitfall 7: Claude Code MCP Config Injection +**What goes wrong:** The host's claude-code Nix derivation injects `--mcp-config` pointing to a Nix store path with host-specific MCP servers (e.g., charlie-comunica, charlie-memory referencing ~/agent/). +**Why it happens:** The host's Nix package wraps claude with hardcoded MCP paths. +**How to avoid:** This is actually fine for Phase 1 -- the MCP servers won't be accessible inside the sandbox (no ~/agent/ mounted) and will silently fail. Future phases might want to strip or override this. No action needed now. +[VERIFIED: checked the MCP config at `/nix/store/5iv9id24chdvf39929rya0rvyjrl0p8f-claude-code-mcp-config.json` -- references host paths] + +### Pitfall 8: /usr/bin/env Missing +**What goes wrong:** Scripts with `#!/usr/bin/env bash` shebangs fail. +**Why it happens:** tmpfs root has no /usr/bin/env. Many scripts and Node.js npm scripts use this shebang. +**How to avoid:** `--symlink /usr/bin/env "$(which env)"` or `--symlink $(which env) /usr/bin/env`. bwrap supports `--symlink` to create symlinks inside the sandbox. +**Warning signs:** "bad interpreter: /usr/bin/env: no such file or directory". +[ASSUMED: standard issue with minimal sandboxes] + +## Code Examples + +### Flake Structure +```nix +# flake.nix +{ + description = "claudebox - sandboxed Claude Code"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + nix-index-database = { + url = "github:nix-community/nix-index-database"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, nix-index-database, ... }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + comma-with-db = nix-index-database.packages.${system}.comma-with-db; + in { + packages.${system} = { + claudebox = pkgs.writeShellApplication { + name = "claudebox"; + runtimeInputs = [ + pkgs.bubblewrap + pkgs.coreutils + pkgs.git + pkgs.curl + pkgs.jq + pkgs.ripgrep + pkgs.fd + pkgs.nix + comma-with-db + pkgs.bash + pkgs.nodejs + ]; + text = builtins.readFile ./claudebox.sh; + }; + default = self.packages.${system}.claudebox; + }; + }; +} +``` +[ASSUMED: flake structure based on standard nixpkgs patterns] + +### Signal Handling and Exit Code +```bash +# At the end of claudebox.sh -- exec replaces the shell process +# so signals go directly to bwrap->claude, and exit code passes through +exec bwrap \ + ... \ + -- "$CLAUDE_BIN" --dangerously-skip-permissions "$@" +``` +[VERIFIED: exec ensures PID 1 in the script is bwrap, Ctrl+C propagates to children] + +### /usr/bin/env Symlink +```bash +# In the bwrap args -- coreutils provides env +--symlink "$(command -v env)" /usr/bin/env +``` +Note: `--symlink` creates `TARGET LINK_NAME` (dest is the symlink path). The `env` binary is in coreutils which is in the sandbox PATH. +[ASSUMED: bwrap --symlink syntax] + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `legacyPackages` for comma-with-db | `packages` output | Recent | Must use `nix-index-database.packages.${system}.comma-with-db` [VERIFIED: deprecation warning on legacyPackages] | +| claude-code from npm | claude-code from nixpkgs | 2025 | Available as `pkgs.claude-code` but version 2.0.51 vs host's 2.1.70 [VERIFIED: nix eval] | +| bwrap 0.9.x | bwrap 0.11.0 | 2025 | Current nixpkgs has 0.11.0 [VERIFIED: nix eval + host binary] | + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | bwrap processes mounts left-to-right, later mounts shadow earlier | Pitfalls #3 | Wrong mount ordering could hide CWD | +| A2 | /etc/passwd and /etc/group are needed for user resolution | Pitfalls #6 | Tools might fail with "no name" if omitted | +| A3 | `--symlink` creates symlinks inside sandbox with syntax `--symlink TARGET LINKNAME` | Code Examples | /usr/bin/env shebang scripts would fail if wrong | +| A4 | jq, ripgrep, fd are standard nixpkgs packages | Standard Stack | Build would fail if package names differ | +| A5 | flake.nix structure with writeShellApplication + builtins.readFile | Code Examples | Nix build would fail if API differs | + +## Open Questions + +1. **Claude Code source: host vs flake input** + - What we know: Host has claude-code 2.1.70 (custom derivation with MCP injection), nixpkgs has 2.0.51 + - What's unclear: Should claudebox depend on the host's claude or bundle its own? + - Recommendation: Discover claude from host PATH at runtime (`CLAUDE_BIN=$(command -v claude)`). This avoids version management and respects the host's claude-code configuration. The script should fail fast with a clear error if `claude` is not found. + +2. **XDG_RUNTIME_DIR inside sandbox** + - What we know: It's in the allowlist (SAND-03), typically `/run/user/1000` on the host + - What's unclear: Whether to bind-mount the host's XDG_RUNTIME_DIR or create a tmpfs one + - Recommendation: Set `--setenv XDG_RUNTIME_DIR /tmp` inside the sandbox (D-04 says sandbox-generated). Don't mount the host's runtime dir as it may contain secret sockets. + +3. **`~/.claudebox` creation** + - What we know: SAND-08 says bind-mount `~/.claudebox` as `~/.claude` + - What's unclear: Who creates `~/.claudebox` if it doesn't exist? + - Recommendation: Script should `mkdir -p ~/.claudebox` before bwrap invocation if it doesn't exist. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| bubblewrap | Sandbox core | Yes | 0.11.0 | -- | +| nix | Package management | Yes | Lix 2.93.3 | -- | +| git | VCS operations | Yes | available on host | -- | +| curl | HTTP requests | Yes | 8.17.0 in nixpkgs | -- | +| nodejs | Claude Code runtime | Yes | 24.13.0 | -- | +| claude-code | The wrapped tool | Yes | 2.1.70 on host | nixpkgs 2.0.51 | +| comma-with-db | On-demand packages | Yes | 2.3.3 via flake | -- | +| Nix daemon socket | nix shell/comma | Yes | /nix/var/nix/daemon-socket/socket | -- | + +**Missing dependencies with no fallback:** None. + +## Project Constraints (from CLAUDE.md) + +- **Stack:** Nix derivation + shell script only. No Docker, systemd, or external dependencies beyond nixpkgs. +- **Sandbox:** Own bwrap call. Not delegating to Claude Code's `--sandbox` or Nix's build sandbox. +- **Env model:** Allowlist, not denylist. Start empty, add explicitly. +- **Commits:** Conventional commits, minimal/succinct messages. +- **NixOS:** Changes go through the flake. + +## Sources + +### Primary (HIGH confidence) +- Host bubblewrap 0.11.0 -- `bwrap --version`, `bwrap --help`, live sandbox tests +- Host Nix/Lix 2.93.3 -- `nix --version`, `nix eval` commands +- nixpkgs bubblewrap -- `nix eval nixpkgs#bubblewrap.version` = "0.11.0" +- nix-index-database flake -- `nix eval` + `nix flake show` confirmed `packages.x86_64-linux.comma-with-db` (2.3.3) +- Claude Code binary inspection -- wrapper chain confirmed: bash -> bash (env setup) -> node cli.js +- NixOS /etc structure -- live inspection of symlink chains for resolv.conf, ssl, hosts, nsswitch.conf +- Live sandbox tests -- confirmed: clearenv, tmpfs root, nix store mount, daemon socket, DNS resolution, SSL (with /etc/static) + +### Secondary (MEDIUM confidence) +- Host `/etc/nix/nix.conf` -- confirmed experimental-features setting needed inside sandbox +- Host `~/.claude/` directory -- confirmed .credentials.json, config/, history.jsonl structure + +### Tertiary (LOW confidence) +- bwrap `--symlink` syntax -- from training data, not tested in this session + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH -- all packages verified in nixpkgs and on host +- Architecture: HIGH -- core patterns verified with live sandbox tests +- Pitfalls: HIGH -- most pitfalls discovered and verified through testing +- Flake structure: MEDIUM -- writeShellApplication API assumed from training, not doc-verified + +**Research date:** 2026-04-09 +**Valid until:** 2026-05-09 (stable tools, 30-day window)