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 Aug 5, 2026 · tandem@0.7.2 · engine 0.38.1 / ruleset 42

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

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.

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], {
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(`${bootstrap.engineUrl}/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) => {

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 →