SkillTotal

Is Tandem MCP server safe?

Some risk - review before installing
Notable — review in context (capabilities are not malware):
  • 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

npm_package · https://github.com/frumu-ai/tandem
MEDIUM
40
/ 100 risk score
Snapshot · scanned Sep 29, 2026 · tandem@0.7.2 · engine 0.53.0 / ruleset 60

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.

Capabilities — what this component can do (not a risk score):
delegated authenticationfilesystem readfilesystem writeinstall time executionmcp tools detectednetwork egressshell execution

Behavioral traits

How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.

Tool surface
Tool Usage
Execution authority
Tool Access Control / Direct Tool Access
Filesystem reach
Tool Execution Context
Network egress
Interaction & Communication / Direct Communication
Delegated authentication
Tool Execution Context / User Delegated Credentials
Supply-chain provenance risk
General Protections / Supply Chain

Findings (13)

HIGHPossible command injection (exec with dynamic command)ST-CMDI-NODE

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}"`);

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.

HIGHnpm install-time lifecycle hookST-INSTALL-NPM

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.

HIGHDefense-evasion command idiomST-SHELL-EVASION

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.

HIGHNode.js shell/command executionST-SHELL-NODE

The component can run operating-system commands or spawn processes.

import { spawn } from "child_process";
const child = spawn(process.execPath, [setupLegacyPath, ...args], {
const child = spawn(process.execPath, [runtimePath, "--env-file", explicitEnvFile].filter(Boolean), {
const { spawn, execSync } = require('child_process');
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}"`);
const engine = spawn(enginePath, [], {
const tui = spawn(tuiPath, process.argv.slice(2), {
import { spawn } from "child_process";
const child = spawn(process.execPath, [setupLegacyPath, ...args], {
import { spawnSync } from "node:child_process";
const result = spawnSync("docker", ["compose", "up", "--build", ...process.argv.slice(2)], {
const child = spawn(process.execPath, [runtimePath, "--env-file", explicitEnvFile].filter(Boolean), {

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.

HIGHPython shell/command executionST-SHELL-PY

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 == 0
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 == 0
result = 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 == 0
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 == 0
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.

MEDIUMNode.js filesystem readST-FS-NODE-READ

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.

MEDIUMNode.js filesystem write/deleteST-FS-NODE-WRITE

The component writes or deletes files on disk.

const file = fs.createWriteStream(destPath);
fs.unlink(destPath, () => { });
fs.unlink(destPath, () => { });
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);
const file = fs.createWriteStream(archivePath);
const file = fs.createWriteStream(path.join(binDir, artifactName));
fs.unlink(path.join(binDir, artifactName), () => { });
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.

MEDIUMPython filesystem readST-FS-PY-READ

The component reads files from disk.

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.

MEDIUMPython filesystem write/deleteST-FS-PY-WRITE

The component writes or deletes files on disk.

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.

MEDIUMNode.js network egressST-NET-NODE

The component makes outbound network requests.

const response = await globalThis.fetch(
const response = await fetch(`${ENGINE_URL}/global/health`, {
const response = await fetch(`${ENGINE_URL}/config/providers`, {
response = await fetch(`${ENGINE_URL}${path}`, {
const res = await fetch(`${url.toString().replace(/\/$/, "")}/global/health`, {
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`, {
https.get(downloadUrl, { headers: { 'User-Agent': 'tandem-cli' } }, (response) => {
https.get(`https://api.github.com/repos/${TANDEM_REPO}/releases`, { headers: { 'User-Agent': 'tandem-cli' } }, (res) => {
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { headers: { 'User-Agent': 'node' } }, (res) => {
res = await fetch(`${this.baseUrl}${path}`, {
res = await fetch(`${this.baseUrl}${path}`, {
res = await fetch(`${this.baseUrl}${path}`, {

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.

MEDIUMPython network egressST-NET-PY

The component makes outbound network requests.

req = urllib.request.Request("https://hub.docker.com", headers=headers)

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.

LOWDelegated authentication (OAuth 2.0 / OIDC)ST-AUTH-DELEGATED

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.

LOWMCP tool surface detectedST-MCP-DETECTED

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 component

How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →