Shell tool — let the model run commands in the project
Overview
mouaif ships a built-in shell tool the model can invoke. The tool runs a command in the project's working directory, captures stdout / stderr / exit code / duration, and returns the result to the model. Calls and results ride the chat as tool_call and tool_result SSE events (see docs/features/ai-client.md for the reserved event names) and are persisted with the transcript and written to the trace file when tracing is on. The tool is off by default per project; the user opts in through project settings and gates every call with the authorization system (docs/features/tool-authorization.md).
Usage
Enabling the tool
In Settings → Project settings → Tools (reachable from a project card's ⋯ menu → Settings…), the Shell tool carries an Off/Ask/Allow authorization segment. It persists tools.shell: { mode, allowlist } on the project's .mouaif.json. The mode is the enablement gate: off hides the tool from the model and rejects any call with ETOOL_DISABLED; ask prompts on first use; allow runs every call; allowlist auto-approves calls whose summary matches one of the patterns (see docs/features/tool-authorization.md). With a non-off mode, the AI client's outgoing request advertises the tool to the model using the OpenAI-compatible tool-call shape:
{
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command in the project directory through mouaif, the local AI coding assistant that provides this chat. Commands execute on <os> via <shell> (e.g. \"Windows via Command Prompt (cmd.exe) — Windows command-line syntax (cmd.exe batch-style quoting and escaping; not POSIX sh/bash)\" or \"macOS via zsh (/bin/zsh) — POSIX sh syntax\"). Returns stdout, stderr, and exit code. Non-interactive only: the child has no stdin, so REPLs, prompts, and commands that read from stdin fail or exit immediately — run the one-shot/flagged form instead.",
"parameters": {
"type": "object",
"properties": {
"cmd": { "type": "string", "description": "The command to run, as a single string. Must be non-interactive (no stdin input, no REPL, no prompts)." },
"shell": { "type": "string", "description": "Optional. The exact interpreter to run the command with, e.g. \"cmd.exe\" (default), \"pwsh\", or \"powershell.exe\" on Windows; a path to a sh-compatible binary on POSIX. Falls back to the platform default when omitted." },
"timeoutMs": { "type": "integer", "description": "Optional per-call timeout, 1 ms - 10 min. Default 30 000." }
},
"required": ["cmd"],
"additionalProperties": false
}
}
}
The tool spec makes the shell dialect explicit — cmd.exe on Windows is not POSIX sh, so the model is told up front which syntax to write (see Model awareness below). An optional shell parameter lets the model (or a REST caller) pick a different interpreter for a single call; it is validated before use.
In a chat
When the model decides to call the tool, the server intercepts the call (the model only sees the tool's description; the actual execution lives behind the mouaif server), runs the command in projectDir, and forwards the result back to the upstream as a tool message before continuing the stream. This is a real multi-turn loop: the model can call the tool, read the result, and call again until it considers the task complete or the user aborts the request. The chat UI shows every call and result inline in the conversation.
The wire shape on the SSE stream:
event: tool_call
data: { "id": "call_abc123", "name": "shell", "args": { "cmd": "npm test", "timeoutMs": 60000 } }
event: shell_output
data: { "id": "call_abc123", "stream": "stdout", "delta": "> project@1.0.0 test\n" }
event: shell_output
data: { "id": "call_abc123", "stream": "stderr", "delta": "npm warn ...\n" }
event: tool_result
data: { "id": "call_abc123", "name": "shell", "ok": true, "result": { "stdout": "...", "stderr": "", "exitCode": 0, "durationMs": 4213, "identity": "mouaif shell · macOS · zsh (/bin/zsh)" } }
shell_output frames are live, best-effort output deltas emitted while the command is still running. They are never persisted to the transcript; the final tool_result carries the complete (truncated) output. The identity field names the software (mouaif shell) plus the OS and the exact shell that ran the command; the same line is prepended to the first model-facing tool message so the model always knows which environment executed its command.
The chat composer also accepts an @shell <cmd> command that runs the tool directly without going through the model. The output is rendered in the chat as a tool_result block, with the same live preview as a model-initiated call: the endpoint streams each stdout/stderr chunk to the card while the command is still running. This is the same code path as a model-initiated call — the only difference is that there is no prior tool_call from the model and the result is shown without a follow-up assistant message.
Behavior
Working directory. Commands run in
projectDir. The runner resolves the path and refuses anything outside the project root (..segments, absolute paths, symlinks that point outside) withEOUTSIDE_PROJECT. The runner ispath.join-aware; it does not shell-cdfor the user.Shell. The command is run with the user's login shell (
$SHELLon POSIX,cmd.exe /d /s /con Windows). It is a single string passed verbatim; there is no command parsing or argument splitting. Pipes, redirects, and&&chains are the user's responsibility and are not interpreted by mouaif. On Windows the runner passes the flags as separate argv elements and wraps the command in an extra pair of quotes withwindowsVerbatimArgumentssocmd /squote-stripping does not mangle inner quotes (e.g.node -e "console.log(1+1)"). An optional per-callshelloverride reuses the same quoting rules when it resolves tocmd.exe/cmdand plain-cotherwise, so the override can never become an arbitrary-arguments injection.Env. The child inherits the parent process's environment, minus a small denylist (
LD_PRELOAD,LD_LIBRARY_PATH,DYLD_INSERT_LIBRARIES,NODE_OPTIONS) to prevent trivial tool escape.PATHis preserved.Sandboxing. The runner does not provide OS-level sandboxing (containers, seccomp,
bwrap). It is the user's responsibility to enable the tool only on projects they trust. The Settings UI shows a warning when the toggle is flipped on, and the authorization system requires explicit approval per call by default.Timeouts. A per-call
timeoutMsis honored; the default is 30 s, the ceiling is 10 min. On timeout the child is killed (SIGTERM, then SIGKILL after 5 s) and the result is{ ok: false, error: 'timed out', code: 'ETIMEDOUT', durationMs: <actual elapsed ms> }. A child that ignores SIGTERM stays tracked for the exit-hook reap; its latecloseis ignored.The project's ceiling wins over the model's request.
timeoutMsfrom a tool call is resolved by the authorization gate againsttools.shell.defaultTimeoutMs/maxTimeoutMs(clamp(requested || default, 1, max)) before the command runs, and the clamped value is what the approval card shows. A model that asks for 10 minutes in a project capped at 30 s gets 30 s. Theshelltool's own 10-minute ceiling applies on top when the project sets no cap.Output size cap. stdout and stderr are truncated to a per-call cap (default 256K chars each, configurable via
app.shellOutputMaxBytes). The cap counts characters (UTF-16 code units), not bytes, so multi-byte output is never split mid-codepoint and the marker matches what the model and the UI read. Truncation adds a final\n...[truncated at 262144 chars]line; the original exit code is preserved.Multi-turn loop. Tool results are fed back to the model as
toolmessages, so the model can chain calls (read a file, run a build, read the error, fix it). There is no fixed tool-turn limit; cancellation comes from the user aborting the active request.Non-interactive only. The child runs with no stdin (
stdio: ['ignore', 'pipe', 'pipe']), so REPLs and commands that read stdin (barenode,cmdbuiltins,npm init, ...) fail or exit immediately — e.g.Input redirection is not supported, exiting the process immediately.on Windows. The tool spec declares this constraint; use one-shot forms (node -e "...",npm test, flags) instead.Identical-call circuit breaker. The tool loop has no turn limit, so a model retrying the exact same failing call (same tool + same arguments) would spin forever. After 3 consecutive identical calls the server refuses the 4th+ with an
ELOOPtool error telling the model to vary the command or answer in plain text. Any different call resets the streak.Live output preview. While the command runs, decoded stdout/stderr chunks ride the SSE stream as
shell_outputevents so the chat card can stream a live preview (same for shell calls nested inside a subagent, which re-emit assubagent_eventwithkind: "shell_output"). These frames are UI-only — never persisted and never sent back to the model; the authoritative output is the singletool_resultafter the command exits.Disabled when off. A project with
tools.shell.modeset tooffreturnsETOOL_DISABLEDfor any call (model-initiated or@shell). The authorization gate is the single source of truth for enablement; there is no separateenabledflag.Persisted with the chat.
tool_callandtool_resultevents are written to<projectDir>/.mouaif.traces.<chatId>.json(when tracing is on) and to the per-chat NDJSON trace astool_callandtool_resultlines.No new runtime dependencies. The runner is built on
node:child_process.spawnonly. No third-party shell wrappers.
Related
docs/features/ai-client.md —
tool_callandtool_resultSSE event names.docs/features/tool-authorization.md — every shell call passes through the authorization gate.
docs/features/trace.md —
tool_callandtool_resultlines on the NDJSON trace.