Skip to content
archived Visibility internal Owner erik@uvilo.com Approver _ Created 2026-04-27 Updated 2026-04-27

Bash Refactor Plan 2

Scope: Implement forge-bash core — execution, output processing, rm blocking, and audit logging

Spec: Bash Refactor Spec

Prior plan: Bash Refactor Plan 1


Task 1 — Implement Layer 1: Unix Execution

Spec Section 5, Layer 1: “Commands are passed verbatim to the shell via child_process.execFile with the shell binary (bash or fallback sh) and -c flag.”

In src/index.ts, implement the core execution logic inside the run tool handler:

  1. Shell binary detection: Use execSync('which bash') to find bash. Fall back to /bin/sh if bash is not found. Store as a module-level constant.

  2. Command execution: Use promisify(execFile) with the detected shell binary and ['-c', command]. Options:

    • cwd: '/workspace'
    • timeout: 300_000 (5 minutes)
    • maxBuffer: 50 * 1024 * 1024 (50 MB)
    • encoding: 'buffer' (returns Buffer for binary detection)
  3. Railway env injection: At module top level (before server start), read /proc/1/environ, split on \0, parse each KEY=VALUE pair, and set any not already in process.env. Wrap in try/catch (file doesn’t exist outside Railway).

  4. Error handling:

    • Timeout: catch the execFile error, check for err.killed (timed out) → return ERROR: Command timed out after 300 s. with [exit:124 | Xms]
    • Other execution failures: return ERROR: Execution failed: {message} with [exit:1 | Xms]
    • Non-zero exit codes are NOT errors at this layer — they produce normal output with the exit code in the metadata footer

Task 2 — Implement Layer 2: LLM Presentation

Spec Section 5, Layer 2: “After execution, the raw output is processed through these transformations in order.”

Process the raw Buffer output through these steps in order:

  1. Binary guard: Check stdout Buffer for null bytes (buf.includes(0)). If no null bytes, check first 1024 bytes for >10% control characters (byte values 0–31, excluding TAB=9, LF=10, CR=13). If binary detected, return error message: "Binary output detected. Use: file <path> or: xxd <path> | head -20" with the metadata footer.

  2. Overflow truncation: Decode the buffer to UTF-8 (buf.toString('utf-8') — Node replaces invalid sequences with U+FFFD). Check if output exceeds 200 lines or 50 KB (50 * 1024 characters). If so:

    • Write full output to /tmp/forge-bash/cmd-N.txt (increment N per invocation, using a module-level counter starting at 1)
    • Truncate response to first 200 lines
    • Append navigation hint: Output truncated. Full output written to /tmp/forge-bash/cmd-N.txt
  3. Stderr attachment: If exit code is non-zero and stderr is non-empty, decode stderr and append it after the stdout section. Format:

    {stdout}
    --- stderr ---
    {stderr}
  4. Metadata footer: Append [exit:N | Xms] to every response. N is the exit code, X is the duration in milliseconds (captured from Date.now() before and after execution).

Ensure the order is strictly: binary guard → overflow truncation → stderr attachment → metadata footer.


Task 3 — Implement rm Blocking

Spec Section 7: “Before execution, the command chain is parsed into segments… If the first token is rm or ends with /rm, the command is blocked.”

  1. Chain parser: Implement a function parseChain(command: string): string[] that splits a command string on |, ||, &&, ; while respecting single and double quotes. This is a direct translation of the Python _split_chain function (~40 lines). Algorithm:

    • Iterate character by character, tracking quote state (in-single-quote, in-double-quote, or neither)
    • When not inside quotes, recognize the operators and split
    • Trim whitespace from each segment
  2. rm detection: Before executing, parse the command chain. For each segment:

    • Strip leading VAR=value assignments (sequences of WORD=WORD at the start, space-separated)
    • Take the first remaining token (split on whitespace, take [0])
    • Check if token is rm or ends with /rm
    • If any segment matches, return immediately: "rm is blocked. Use the uvilo-trash MCP tool instead." with [exit:-1 | 0ms]
  3. Audit log the blocked command (Task 4 handles the audit function itself).


Task 4 — Implement Audit Logging

Spec Section 6: “Every executed command (including blocked commands) is logged to a daily TSV file.”

  1. Audit function: async function auditLog(command: string, exitCode: number, durationMs: number): Promise<void>

  2. File path: /tmp/forge-bash/audit-YYYY-MM-DD.tsv where the date is UTC. Create /tmp/forge-bash/ directory on first write (mkdirSync with recursive: true).

  3. Entry format: One line per command:

    {ISO timestamp}\t{command}\t{exitCode}\t{durationMs}
    • ts: new Date().toISOString() (e.g., 2026-04-26T14:32:01.234Z)
    • command: Full command string with tabs escaped to \\t and newlines escaped to \\n
    • exitCode: Integer (use -1 for blocked commands)
    • durationMs: Integer (use 0 for blocked commands)
  4. Append mode: Use appendFileSync (synchronous is fine — audit logging must not lose entries). Each appendFileSync call appends one line ending with \n.

  5. Call sites: Call auditLog in both the rm-blocked path and the normal execution path (after Layer 2 processing).


Task 5 — Build, Test, and Validate

  1. Run npm run typecheck — must pass with zero errors.
  2. Run npm run build — must produce dist/index.js.
  3. Manual smoke test — run the built server with a simple command:
    echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run","arguments":{"command":"echo hello"}}}' | node dist/index.js
    This sends a raw MCP request. Verify:
    • Output contains “hello”
    • Output contains [exit:0 |
    • Audit log file was created at /tmp/forge-bash/
  4. Test rm blocking:
    echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run","arguments":{"command":"rm test.txt"}}}' | node dist/index.js
    Verify the blocked message appears and audit log records exitCode -1.
  5. Commit the implementation.