Is Obsidian Second Brain safe?
- Python shell/command execution
- Remote pipe-to-shell execution
- Dangerous MCP tool capability
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 40/100 (medium).
obsidian-second-brain-research 0.17.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)
Code runs a local AI coding agent (claude, gemini, codex, q …) with a flag that turns off its approval prompts. The agent then acts with the user's credentials and file access and nobody confirms what it does; the s1ngularity and Shai-Hulud npm campaigns used exactly this to search disks for secrets.
claude --dangerously-skip-permissions --strict-mcp-config \
Fix: Confirm why the component drives an AI agent unattended and what prompt it sends. Prefer explicit, narrow tool allowlists over disabling approvals.
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.
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. (4 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.
p = subprocess.run(argv, cwd=str(vault), capture_output=True, text=True, timeout=timeout)
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(
parts + [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"],
# git writes commit messages as UTF-8; decoding them with the locale
# codepa …return subprocess.run(cmd, capture_output=True, timeout=timeout, check=True)
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.
text = claude_md.read_text(encoding="utf-8-sig")
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]
index = json.loads(index_path.read_text(encoding="utf-8"))
with (vault / "log.md").open("a", encoding="utf-8") as fh:text = idx.read_text(encoding="utf-8-sig")
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")
body = (vault / path).read_text(encoding="utf-8", errors="ignore")
rows = [json.loads(x) for x in cases_path.read_text(encoding="utf-8").splitlines() if x.strip()]
cases_path.read_text(encoding="utf-8").splitlines() if x.strip()]
body = md.read_text(encoding="utf-8", errors="ignore")
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.
f.write_text(f"---\ntype: log\ndate: {day}\nai-first: true\n---\n\n# {day}\n\n", encoding="utf-8")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 agent\n\n"
f"Daily note for {day} ({dow}). Journal entries captured via the Telegram jo …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 agent\n\n{body}\n",
encoding="utf-8")CATCHUP.write_text(
"---\ntype: catchup-queue\nai-first: true\n---\n\n"
"## For future agent\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, ensure_ascii=False), 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))
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.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
with requests.get(audio_url, stream=True, timeout=60) as resp:
resp = requests.post(
GROQ_TRANSCRIPTION_URL,
headers={"Authorization": f"Bearer {api_key}"},
files={"file": (audio_path.name, f, "audio/mpeg")},
data={"model": GROQ_MODEL, "response_format": …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)
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": {# the FIRST @mcp.tool() it walks, which aborts registration for all of them.
mcp = FastMCP("obsidian-second-brain")@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 →