Is Obsidian Second Brain safe?
- Python shell/command execution
- Node.js shell/command execution
- Remote pipe-to-shell execution
obsidian-second-brain-research is an AI python_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 9 risky constructs are reported for review. It can: filesystem read, filesystem write, mcp tools detected, network egress and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 20/100 (low).
obsidian-second-brain-research 0.14.0
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of Obsidian Second Brain'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 (9)
An MCP tool exposes a powerful capability (files, shell, network, browser, or credentials).
@mcp.tool() def obsidian_vault_health() -> str:
Why it matters: Wired into an agent, these grant it real access to your machine — confirm each is required.
Fix: Confirm each powerful tool is required and constrained; broad MCP tools (shell/filesystem/network) grant an agent significant host access.
An MCP server entry launches a command on your host.
"command": "uv",
Why it matters: Trusting the manifest means running that binary — verify what it is and where it comes from.
Fix: Verify the launched command and its source before trusting this MCP server configuration.
The component can run operating-system commands or spawn processes.
const m = FRONTMATTER.exec(text);
const body = fm === null ? note.text : note.text.slice(FRONTMATTER.exec(note.text)![0].length);
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.
A remotely fetched payload is piped straight into a shell (e.g. `curl … | bash`). The component runs code downloaded at runtime, which is unreviewable and a common second-stage delivery vector. (2 occurrence(s) shown as evidence).
Linux) uv_hint="curl -LsSf https://astral.sh/uv/install.sh | sh" ;;
Fix: Download to a file, inspect it, and run a pinned/verified copy instead of piping a network response directly into a shell.
The component can run operating-system commands or spawn processes.
subprocess.run(cmd, check=True, capture_output=True, text=True)
proc = subprocess.run(
[UV_BIN, "run", "-m", module, url],
cwd=str(SKILL_REPO), capture_output=True, text=True, timeout=300, env=os.environ,
)out = subprocess.run(["git", "-C", str(root), *args],
capture_output=True, text=True, check=False)result = subprocess.run(
["bash", "scripts/build.sh"],
cwd=REPO_ROOT, capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False,
)proc = subprocess.run(
shlex.split(cmd) + [q],
capture_output=True, text=True, timeout=120,
)out = subprocess.run(
["git", "-C", repo, "log", f"-n{limit}", "--no-merges",
"--pretty=format:%h%x1f%ad%x1f%s", "--date=short"],
capture_output=True, text=True, check=False,
)subprocess.run(open_cmd, check=False, timeout=5)
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False, timeout=600)result = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(Path(video_path).resolve())],
capture_output=True, text=True, timeout=120)result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
result = subprocess.run(
# CLAUDE.md bans substitution Unicode in "source files, docs, or
# commits" generally, but this only ever scanned md/py/sh, so YAML,
# TOML, JSON, and dotfiles were ungated. There …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.
return header + claude_md.read_text(encoding="utf-8")
with (d / f"recall-{datetime.now():%Y-%m-%d}.jsonl").open("a") as f:head = (vault / r["path"]).read_text(encoding="utf-8-sig", errors="ignore")[:400]
for line in path.read_text(encoding="utf-8").splitlines():
index = json.loads(index_path.read_text(encoding="utf-8"))
return path.read_text(encoding="utf-8-sig", errors="replace")[:limit]
for line in CONFIG.read_text().splitlines():
return out.read_text().strip() if out.exists() else ""
text = note.read_text(encoding="utf-8")
text = note.read_text(encoding="utf-8")
return json.loads(LASTIMG.read_text())
return int(STATE.read_text().strip())
data = json.loads(pkg.read_text(encoding="utf-8"))
text = pyproject.read_text(encoding="utf-8")
content = template_path.read_text(encoding="utf-8")
m = FM_RE.match(path.read_text(encoding="utf-8-sig"))
if not p.exists() or p.read_text(encoding="utf-8") != content:
"SKILL_ROOT" in p.read_text(encoding="utf-8", errors="ignore")
text = md.read_text(encoding="utf-8", errors="ignore")
text = readme.read_text(encoding="utf-8")
cases_path.read_text(encoding="utf-8").splitlines() if x.strip()]
body = md.read_text(encoding="utf-8", errors="ignore")
body = md.read_text(encoding="utf-8", errors="ignore")
cases = [json.loads(line) for line in cases_path.read_text(encoding="utf-8").splitlines() if line.strip()]
cache = json.loads(index_path.read_text())
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.
src.write_bytes(audio)
note.write_text(
f"---\ntype: daily\ndate: {day}\nday-of-week: {dow}\ntags:\n - daily\n"
f"ai-first: true\n---\n\n## For future Claude\n\n"
f"Daily note for {day} ({dow}). Journal entries captured via the Telegram j …note.write_text(text.rstrip() + f"\n\n{header}\n\n{block}", encoding="utf-8")note.write_text(new_text, encoding="utf-8")
note.write_text(text, encoding="utf-8")
(folder / fname).write_bytes(img_bytes)
LASTIMG.write_text(json.dumps(d))
STATE.write_text(str(n))
(att / fname).write_bytes(data)
note.write_text(fm + body.rstrip() + f"\n\n![[{fname}]]\n", encoding="utf-8")note.write_text(
f"---\ntype: {ntype}\ndate: {today}\ntags: [{ntype}, telegram-capture]\nai-first: true\n---\n\n"
f"## For future Claude\n\n{body}\n",
encoding="utf-8")CATCHUP.write_text(
"---\ntype: catchup-queue\nai-first: true\n---\n\n"
"## For future Claude\n\nUnprocessed captures from the Telegram journal bot, "
"newest at the bottom. Each line is `- [ ] date time …path.write_text(content.strip() + "\n", encoding="utf-8")
target.write_text(content.replace(placeholder, folder), encoding="utf-8")
(d / ".gitkeep").write_text("", encoding="utf-8")p.write_text(content, encoding="utf-8")
readme.write_text(updated, encoding="utf-8")
p.write_text(content, encoding="utf-8")
(cdir / f"{name}.jsonl").write_text(
"\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8")index_path.write_text(json.dumps(out), encoding="utf-8")
dest.write_text(fmblock + convert_links(body, rel).lstrip("\n"), encoding="utf-8")(out / "index.md").write_text("\n".join(idx), encoding="utf-8")(out / "log.md").write_text(vlog.read_text(encoding="utf-8-sig", errors="replace"),
encoding="utf-8")path.write_text(json.dumps(results, default=encode_results))
path.write_text(full)
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.
import urllib.request
req = urllib.request.Request(url, data=body, headers=headers if _EMBED_BACKEND == "openai"
else {"Content-Type": "application/json"})with urllib.request.urlopen(req, timeout=10) as r:
import requests
r = requests.get(f"{API}/{method}", params=params, timeout=30)data = requests.get(f"{FILE_API}/{path}", timeout=120).contentr = requests.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {OPENAI}"},
files=files,
timeout=120,
)r = requests.post(
"https://api.anthropic.com/v1/messages",
headers={"x-api-key": ANTHROPIC, "anthropic-version": "2023-06-01",
"content-type": "application/json"},
json=body, timeout=90,
)import urllib.error
import urllib.request
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=3) as r:req = urllib.request.Request(url, data=body, headers=headers)
with urllib.request.urlopen(req, timeout=120) as r:
import requests
r = requests.post(API_URL.format(model=model), json=body, headers=headers, timeout=180)
import requests
r = requests.post(API_URL, json=body, headers=headers, timeout=180)
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
sess = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff,
status_forcelist=(500, 502, 503, 504),
allowed_methods=("GET", "HEAD"),
raise_on_status=False,
)adapter = HTTPAdapter(max_retries=retry)
import requests
r = requests.post(API_URL, json=body, headers=headers, timeout=timeout)
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 MCP tool surface (manifest or tool definitions) was found.
"mcpServers": {mcp = FastMCP("obsidian-second-brain")@mcp.tool()
@mcp.tool()
@mcp.tool()
@mcp.tool()
@mcp.tool()
@mcp.tool()
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 →