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.execFilewith the shell binary (bashor fallbacksh) and-cflag.”
In src/index.ts, implement the core execution logic inside the run tool handler:
-
Shell binary detection: Use
execSync('which bash')to find bash. Fall back to/bin/shif bash is not found. Store as a module-level constant. -
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)
-
Railway env injection: At module top level (before server start), read
/proc/1/environ, split on\0, parse eachKEY=VALUEpair, and set any not already inprocess.env. Wrap in try/catch (file doesn’t exist outside Railway). -
Error handling:
- Timeout: catch the
execFileerror, check forerr.killed(timed out) → returnERROR: 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
- Timeout: catch the
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:
-
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. -
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
- Write full output to
-
Stderr attachment: If exit code is non-zero and stderr is non-empty, decode stderr and append it after the stdout section. Format:
-
Metadata footer: Append
[exit:N | Xms]to every response. N is the exit code, X is the duration in milliseconds (captured fromDate.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
rmor ends with/rm, the command is blocked.”
-
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_chainfunction (~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
-
rm detection: Before executing, parse the command chain. For each segment:
- Strip leading
VAR=valueassignments (sequences ofWORD=WORDat the start, space-separated) - Take the first remaining token (split on whitespace, take
[0]) - Check if token is
rmor ends with/rm - If any segment matches, return immediately:
"rm is blocked. Use the uvilo-trash MCP tool instead."with[exit:-1 | 0ms]
- Strip leading
-
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.”
-
Audit function:
async function auditLog(command: string, exitCode: number, durationMs: number): Promise<void> -
File path:
/tmp/forge-bash/audit-YYYY-MM-DD.tsvwhere the date is UTC. Create/tmp/forge-bash/directory on first write (mkdirSyncwithrecursive: true). -
Entry format: One line per command:
ts:new Date().toISOString()(e.g.,2026-04-26T14:32:01.234Z)command: Full command string with tabs escaped to\\tand newlines escaped to\\nexitCode: Integer (use-1for blocked commands)durationMs: Integer (use0for blocked commands)
-
Append mode: Use
appendFileSync(synchronous is fine — audit logging must not lose entries). EachappendFileSynccall appends one line ending with\n. -
Call sites: Call
auditLogin both the rm-blocked path and the normal execution path (after Layer 2 processing).
Task 5 — Build, Test, and Validate
- Run
npm run typecheck— must pass with zero errors. - Run
npm run build— must producedist/index.js. - Manual smoke test — run the built server with a simple command:
This sends a raw MCP request. Verify:
- Output contains “hello”
- Output contains
[exit:0 | - Audit log file was created at
/tmp/forge-bash/
- Test rm blocking: Verify the blocked message appears and audit log records exitCode -1.
- Commit the implementation.