Is Tandem MCP server safe?
- Python shell/command execution
- Node.js shell/command execution
- Possible command injection (exec with dynamic command)
tandem is an AI npm_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 13 risky constructs are reported for review. It can: delegated authentication, filesystem read, filesystem write, install time execution, mcp tools detected, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 40/100 (medium).
tandem 0.7.2
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of Tandem MCP server's authors. Report a false positive.
Behavioral traits
How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.
Findings (13)
The code builds an OS command out of values that can change at runtime, then runs it through a shell.
execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${CACHE_DIR}' -Force"`);execSync(`unzip -o "${archivePath}" -d "${CACHE_DIR}"`);execSync(`tar -xzf "${archivePath}" -C "${CACHE_DIR}"`);execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);execSync(`unzip -o "${archivePath}" -d "${binDir}"`);execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);execSync(`unzip -o "${archivePath}" -d "${binDir}"`);execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${binDir}' -Force"`);execSync(`unzip -o "${archivePath}" -d "${binDir}"`);execSync(`tar -xzf "${archivePath}" -C "${binDir}"`);execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force"`, {execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, {Why it matters: If any of those values come from untrusted input, an attacker can run their own commands on the machine.
Fix: Use execFile/spawn with an argument array instead of exec; never build a shell command string from external input.
package.json runs scripts automatically when the package is installed.
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js"
Why it matters: Install scripts are a favorite supply-chain foothold — they execute on every machine that installs the package.
Fix: Inspect the hook command. Install-time scripts are a common supply chain execution vector; ensure they do nothing beyond a documented build step.
A command uses a known defense-evasion idiom: PowerShell execution-policy bypass / encoded command / hidden window, macOS code-signing bypass, or launching a payload from a world-writable temp directory. These are hallmarks of droppers and rarely appear in legitimate code. (2 occurrence(s) shown as evidence).
`powershell -NoProfile -ExecutionPolicy Bypass -File "${def.scriptPath}"`,powershell -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%"
Fix: Verify why the component bypasses execution policy / code signing or runs from a temp directory; these patterns are characteristic of malware staging.
The component can run operating-system commands or spawn processes.
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);const match = /language-(\w+)/.exec(className || "");
const match = /language-(\w+)/.exec(className || "");
while (item = re.exec(txt)) {!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer …var hljs=function(){"use strict";function e(n){Object.freeze(n);var t="function"==typeof n;return Object.getOwnPropertyNames(n).forEach((function(r){!Object.hasOwnProperty.call(n,r)||null===n[r]||"object"!=typeof n[r]&&"function"!=typeof n[ …!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Mark=t()}(this,function(){"use strict";var e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterato …import { spawn } from "child_process";const child = spawn(process.execPath, [setupLegacyPath, ...args], {import { spawn } from "child_process";const child = spawn(process.execPath, [runtimePath, "--env-file", explicitEnvFile].filter(Boolean), {const child = spawn(
import { spawn } from "child_process";const child = spawn(bin, args, {engineProcess = spawn(
import { spawn } from "child_process";const child = spawn(bin, args, {import { spawn } from "child_process";const child = spawn(command, args, {Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; prefer execFile with an argument array.
The component can run operating-system commands or spawn processes.
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=15
)result = subprocess.run(
["nextflow", "-version"],
capture_output=True,
text=True,
timeout=30
)result = subprocess.run(
["java", "-version"],
capture_output=True,
text=True,
timeout=10
)result = subprocess.run(
['sysctl', '-n', 'hw.memsize'],
capture_output=True, text=True, timeout=5
)aws_available = subprocess.run(
['which', 'aws'],
capture_output=True
).returncode == 0result = subprocess.run(cmd, capture_output=True, text=True)
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=15
)result = subprocess.run(
["nextflow", "-version"],
capture_output=True,
text=True,
timeout=30
)result = subprocess.run(
["java", "-version"],
capture_output=True,
text=True,
timeout=10
)result = subprocess.run(
['sysctl', '-n', 'hw.memsize'],
capture_output=True, text=True, timeout=5
)aws_available = subprocess.run(
['which', 'aws'],
capture_output=True
).returncode == 0result = subprocess.run(cmd, capture_output=True, text=True)
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=15
)result = subprocess.run(
["nextflow", "-version"],
capture_output=True,
text=True,
timeout=30
)result = subprocess.run(
["java", "-version"],
capture_output=True,
text=True,
timeout=10
)result = subprocess.run(
['sysctl', '-n', 'hw.memsize'],
capture_output=True, text=True, timeout=5
)aws_available = subprocess.run(
['which', 'aws'],
capture_output=True
).returncode == 0result = subprocess.run(cmd, capture_output=True, text=True)
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=15
)result = subprocess.run(
["nextflow", "-version"],
capture_output=True,
text=True,
timeout=30
)result = subprocess.run(
["java", "-version"],
capture_output=True,
text=True,
timeout=10
)result = subprocess.run(
['sysctl', '-n', 'hw.memsize'],
capture_output=True, text=True, timeout=5
)aws_available = subprocess.run(
['which', 'aws'],
capture_output=True
).returncode == 0result = subprocess.run(cmd, capture_output=True, text=True)
base_manifest = subprocess.check_output(
["git", "show", f"{base_ref}:{MANIFEST.as_posix()}"],
text=True,
stderr=subprocess.PIPE,
)Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; avoid shell=True.
The component reads files from disk.
const text = fs.readFileSync(logPath, "utf8");
const source = fs.readFileSync(absolute, "utf8");
const source = fs.readFileSync(absolute, "utf8");
return fs.readFileSync(filePath, 'utf8');
const text = fs.readFileSync(readmePath, "utf8");
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(fs.readFileSync(outputFile, "utf8"));
return JSON.parse(fs.readFileSync(filePath, "utf8"));
assert.match(fs.readFileSync(outMd, "utf8"), /tandem-tools/);
assert.equal(JSON.parse(fs.readFileSync(outJson, "utf8")).crates.length, 2);
fs.readFileSync(args.lcov, "utf8"),
const raw = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
return fs.readFileSync(path.join(repoRoot, rel), "utf8");
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
const content = fs.readFileSync(file, "utf8");
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
const source = fs.readFileSync(file, "utf8");
const source = fs.readFileSync(absolute, "utf8");
const source = fs.readFileSync(path.join(repoRoot, sourcePath), "utf8");
const source = fs.readFileSync(path.join(repoRoot, sourcePath), "utf8");
fs.readFileSync(path.join(repoRoot, constantSourcePath), "utf8"),
const source = fs.readFileSync(path.join(repoRoot, sourcePath), "utf8");
const source = fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
const expected = fs.readFileSync(inventoryPath, "utf8");
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
const file = fs.createWriteStream(destPath);
fs.unlink(destPath, () => { });fs.unlink(destPath, () => { });fs.unlinkSync(archivePath);
fs.writeFileSync(def.unitPath, def.content, "utf8");
fs.writeFileSync(def.plistPath, def.content, "utf8");
fs.writeFileSync(def.scriptPath, def.content, "utf8");
const file = fs.createWriteStream(archivePath);
fs.unlink(archivePath, () => {});fs.unlinkSync(archivePath);
const file = fs.createWriteStream(archivePath);
fs.unlink(archivePath, () => {});fs.unlinkSync(archivePath);
fs.rmSync(destPath, { force: true });const file = fs.createWriteStream(path.join(binDir, artifactName));
fs.unlink(path.join(binDir, artifactName), () => { });fs.unlinkSync(archivePath);
import('fs').then(fs => fs.writeFileSync('bench_error.log', err.toString() + '\\n' + err.stack));fs.writeFileSync(outputFile, serialized, "utf8");
fs.writeFileSync(outMd, renderMarkdown(rows));
fs.writeFileSync(outJson, `${JSON.stringify({ crates: rows }, null, 2)}\n`);fs.writeFileSync(inventoryPath, stableJson(inventory));
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component reads files from disk.
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
with open(filepath, "rb") as f:
with open(input_path) as f:
with open(filepath, "r", encoding="utf-8") as f:
with open(reference_path, "r", encoding="utf-8") as f:
with open('/proc/meminfo', 'r') as f:with open(config_file) as f:
with open(config_file) as f:
with open(csv_path, 'r') as f:
with open(GENOMES_FILE) as f:
with open(GENOMES_FILE) as f:
with open(config_path) as f:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
with open(filepath, "rb") as f:
with open(input_path) as f:
with open(filepath, "r", encoding="utf-8") as f:
with open(reference_path, "r", encoding="utf-8") as f:
with open('/proc/meminfo', 'r') as f:with open(config_file) as f:
with open(config_file) as f:
with open(csv_path, 'r') as f:
with open(GENOMES_FILE) as f:
with open(GENOMES_FILE) as f:
with open(config_path) as f:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
with open(temp_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(metadata_path, 'w') as f:
with open(output_path, 'w') as f:
with open(output_path, 'wb') as f:
with open(output_path, 'wb') as f:
shutil.copyfileobj(response, f)
with open(temp_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, "w") as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(output_path, 'w') as f:
with open(metadata_path, 'w') as f:
with open(output_path, 'w') as f:
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component makes outbound network requests.
const response = await globalThis.fetch(
const response = await globalThis.fetch(
const response = await globalThis.fetch(
fetch(url, options),
const response = await fetch(`${ENGINE_URL}/global/health`, {const response = await fetch(`${ENGINE_URL}/config/providers`, {upstream = await fetch(targetUrl, {upstream = await fetch(targetUrl, {upstream = await fetch(targetUrl, {response = await fetch(`${ENGINE_URL}${path}`, {const res = await fetch(`${bootstrap.engineUrl}/global/health`, {const response = await fetch(engineProbeUrl, {const upstream = await fetch(targetUrl, {const res = await fetch(path, {const res = await fetch(`/api/engine/session/${encodeURIComponent(sessionId)}/run`, {await fetch(`/api/engine/session/${encodeURIComponent(sessionId)}/cancel`, {fetch(`/api/engine/session/${encodeURIComponent(sessionId)}/prompt_async?return=run`, {const res = await fetch(
https.get(downloadUrl, { headers: { 'User-Agent': 'tandem-cli' } }, (response) => {Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
The component makes outbound network requests.
import urllib.request
req = urllib.request.Request("https://hub.docker.com", headers=headers)urllib.request.urlopen(req, timeout=10)
req = urllib.request.Request("https://nf-co.re", headers=headers)urllib.request.urlopen(req, timeout=10)
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import requests
response = requests.get(url, timeout=10)
req = Request(url, headers={'User-Agent': 'geo-sra-skill/1.0'})with urlopen(req, timeout=10) as response:
response = requests.get(search_url, timeout=30)
with urlopen(search_url, timeout=30) as response:
response = requests.get(summary_url, timeout=30)
with urlopen(summary_url, timeout=30) as response:
response = requests.get(search_url, timeout=30)
with urlopen(search_url, timeout=30) as response:
response = requests.get(summary_url, timeout=30)
with urlopen(summary_url, timeout=30) as response:
response = requests.get(search_url, timeout=30)
with urlopen(search_url, timeout=30) as response:
response = requests.get(search_url, timeout=30)
with urlopen(search_url, timeout=30) as response:
response = requests.get(summary_url, timeout=60)
with urlopen(summary_url, timeout=60) as response:
Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
An OAuth 2.0 / OpenID Connect delegated-authentication flow was detected (authorization-code / refresh-token / token-exchange grant, an OIDC authorize/discovery endpoint or id_token, or a delegation library). Tools authenticate with the end user's delegated, scoped credentials rather than a long-lived embedded service credential.
return this.req<JsonObject>(`/provider/${encodeURIComponent(providerId)}/oauth/authorize`, {Fix: Delegated auth is a lower-blast-radius execution context than an embedded static credential. Confirm the requested scopes are minimal and that tokens are never logged or forwarded off-host.
An MCP tool surface (manifest or tool definitions) was found.
Why it matters: Just context — review which tools it offers and their permissions.
Fix: Review the declared MCP tools and their permissions.
Check your own component
Run the same evidence-backed scan on any MCP server, agent skill, or package.
Scan your own componentHow we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →