Back to skill

Security audit

noteClaw

Security checks for vulnerabilities and agentic risk

Overview

The skill is mostly coherent, but a path-handling flaw could let OneNote section names cause local writes or deletes outside the intended mirror folder.

Install only if you are comfortable granting Microsoft account read access, storing a refresh token locally, and committing mirrored note content into a local git history. Until the path traversal issue is fixed, avoid syncing notebooks whose section or section-group names may be controlled by other people, and prefer a dedicated private data directory with no unrelated files.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (1)

T09 · Insecure Skill Coding Practices

Error
Location
scripts/graph_sync.py:307
Finding

Graph-controlled section paths permit filesystem escape, arbitrary writes, moves, and recursive deletion

Content
View full analysis

Vulnerability Details

File Location: scripts/graph_sync.py:307-312, 551-554, 732-734; scripts/html2md.py:580-602
Vulnerability Type: Path traversal through remotely supplied notebook section hierarchy
Risk Level: High

Technical Analysis

OneNote section and section-group display names returned by Microsoft Graph are used to construct the rel path without rejecting absolute paths, .. components, or path separators:

python
def walk_group(g, group_id, prefix):
    """recursively enumerate section groups → [(relpath, section_id, name, lastModifiedDateTime)]"""
    log(f"  ↳ group {prefix}")
    out = []
    d, s, _ = g.json(f"{API}/me/onenote/sectionGroups/{group_id}/sections?$select=id,displayName,lastModifiedDateTime")
    if s == 200:
        for sec in d.get("value", []):
            out.append((os.path.join(prefix, sec["displayName"]), sec["id"],
                        sec["displayName"], sec.get("lastModifiedDateTime", "")))
    d, s, _ = g.json(f"{API}/me/onenote/sectionGroups/{group_id}/sectionGroups?$select=id,displayName")
    if s == 200:
        for grp in d.get("value", []):
            out.extend(walk_group(g, grp["id"], os.path.join(prefix, grp["displayName"])))
    return out

Although cache paths are sanitized elsewhere, the corresponding store path uses the raw rel value. Section rename handling can therefore move directories outside the intended store root:

python
old_s = os.path.join(STORE_DIR, old_rel)
new_s = os.path.join(STORE_DIR, rel)
if os.path.isdir(old_s) and not os.path.isdir(new_s):
    os.rename(old_s, new_s)

Disappeared-section cleanup uses the same unvalidated value in a recursive deletion operation:

python
store_p = os.path.join(STORE_DIR, rel)
if os.path.isdir(store_p):
    shutil.rmtree(store_p)

The HTML-to-Markdown conversion path also writes and cleans files beneath directories constructed from the raw section path:

python
dest_dir = os.path.join(STORE_DIR, rel)
os.mak
...[truncated 3333 chars]
Remediation
View remediation

Remediation Suggestions

  1. Map each remote hierarchy component independently

    • Do not preserve remote display names as filesystem paths.
    • Convert every section and group name into a single safe local segment.
    • Reject or encode /, \, NUL/control characters, ., .., drive prefixes, and absolute paths.
    • Prefer stable section/group IDs for local directory names and retain display names only as metadata.
  2. Enforce canonical containment before every filesystem mutation

    • Resolve the intended notebook store root with os.path.realpath().
    • Resolve each candidate path before creating, writing, moving, removing, or recursively deleting it.
    • Require the candidate to remain beneath the canonical root, preferably with os.path.commonpath().
    • Reject the operation if containment cannot be proven.

    Example pattern:

    python
    def confined_path(root, *parts):
        root_real = os.path.realpath(root)
        candidate = os.path.realpath(os.path.join(root_real, *parts))
        if os.path.commonpath([root_real, candidate]) != root_real:
            raise RuntimeError(f"path escapes store root: {candidate}")
        return candidate
    
  3. Apply the guard to every affected operation

    • save_html and Markdown destination construction.
    • Section rename source and destination paths.
    • Orphan-file removal.
    • Disappeared-section shutil.rmtree() cleanup.
    • Any future report or cache path derived from remote names.
  4. Harden destructive cleanup

    • Refuse recursive deletion of the store root itself or any ancestor.
    • Consider deleting only files recorded as generated artifacts rather than recursively deleting a remotely named directory.
    • Use stable IDs in the manifest to identify managed directories.
  5. Add regression tests

    • Test names including .., ., ../../target, absolute POSIX paths, Windows drive and UNC paths, mixed separators, and Unicode separator-like characters.
    • Veri ...[truncated 130 chars]
Vulnerability Patterns
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Rogue AgentSelf-Modification, Session Persistence
  • Behavioral ASTexec() Call, eval() Call, Dynamic Import
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
  • MCP Least PrivilegeUnderdeclared Capability, Wildcard Permission, Missing Permission Declaration
Findings (35)

Tainted flow: 'req' from os.environ.get (line 196, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 128)May include surrounding context.

python
data = urllib.parse.urlencode(body).encode()
    req = urllib.request.Request(TOKEN_ENDPOINT, data=data)
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            nt = json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        body_txt = e.read().decode(errors="replace")

Tainted flow: 'req' from os.environ.get (line 196, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 174)May include surrounding context.

python
"""GET → (status, headers_dict, body_bytes)"""
        req = urllib.request.Request(url, headers=self._headers())
        try:
            with urllib.request.urlopen(req, timeout=90) as r:
                return r.status, dict(r.headers), r.read()
        except urllib.error.HTTPError as e:
            if e.code == 401 and retry:

Tainted flow: 'req' from os.environ.get (line 196, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 199)May include surrounding context.

python
req = urllib.request.Request(url, data=data,
                                     headers=self._headers({"Content-Type": "application/json"}))
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                return r.status, dict(r.headers), r.read()
        except urllib.error.HTTPError as e:
            if e.code == 401 and retry:

Ae1

High
Category
analysis-evasion
Confidence
100% confidence
Finding

Referenced artifact was not completely inspected

Content

Scanner excerpt · SKILL.md (reported line 10)May include surrounding context.

md
> Below, `$SKILL` = this skill's install directory (the folder holding this `SKILL.md`, with `scripts/` inside).

Credential Access

High
Category
Privilege Escalation
Confidence
90% confidence
Finding

The skill directs the agent to handle OAuth callback URLs, exchange them for tokens, and store refresh-capable credentials locally. Even if intended for legitimate auth, this is credential material handling: the full pasted URL can contain an authorization code, and the resulting token file grants ongoing access; combined with the documented broad scope including Files.Read, compromise exposes far more than a single notebook.

Content

Scanner excerpt · SKILL.md (reported line 80)May include surrounding context.

md
- **`--code-url <full URL>`**: pass the whole URL the user pasted back.
- **`--code-file <path>`**: the user saves either the whole URL or just the value after `code=` into a file; the script reads the code from it and deletes the file on success.

Afterwards `graph_sync.py` refreshes the access token on every run; no further authorization is needed unless the token expires or is revoked.

### 4. Optional environment variables

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
92% confidence
Finding

The skill instructs the agent to use shell, network, filesystem, and environment capabilities, but it declares no explicit tool scope or permission boundaries. That creates an authorization mismatch: a host or reviewer cannot tell from the manifest what powerful actions the skill expects, increasing the risk of over-privileged execution and unsafe automation.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
93% confidence
Finding

The instructions direct the agent to create a report file under reports/<notebook>/ and commit it, which changes repository state rather than staying read-only. Because there is no explicit user confirmation step before writing and committing, an automated agent could persist unintended content, alter git history, or expose sensitive note summaries in a durable artifact without the user realizing it.

Content

No source excerpt is available for this finding.

Session Persistence

Medium
Category
Rogue Agent
Confidence
60% confidence
Finding

Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Content

Scanner excerpt · scripts/auth_code.py (reported line 5)May include surrounding context.

python
# -*- coding: utf-8 -*-
"""noteClaw authorization code flow (OAuth2) onboarding

Purpose: obtain a Microsoft Graph delegated token (with refresh_token) and write it to
     <data dir>/.auth/notes_token.json (0600).

Applies to: personal Microsoft accounts. The client is the OneDrive application that

Tainted flow: 'TOKEN_FILE' from os.environ.get (line 43, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/auth_code.py (reported line 151)May include surrounding context.

python
die(f"cannot reach Microsoft services ({e.reason}) — check your network and retry")

    os.makedirs(os.path.dirname(TOKEN_FILE), mode=0o700, exist_ok=True)
    with open(TOKEN_FILE, "w") as f:
        json.dump({
            "token_type": t.get("token_type", "Bearer"),
            "scope": t.get("scope", SCOPE),

Session Persistence

Medium
Category
Rogue Agent
Confidence
81% confidence
Finding

The script persists OAuth refresh and access tokens on disk so the session survives re-runs. Even with chmod 0600, long-lived refresh tokens stored in a local file increase the impact of local compromise, backup leakage, or accidental placement of NOTECLAW_HOME in a shared/synced directory because an attacker obtaining the file can reuse the session to read the user's OneNote data.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 103)May include surrounding context.

python
def refresh_access_token():
    """Refresh the token and write it back to the token file (kept at 0600).
    Credentials prefer the client_id recorded in the token file (the one actually
    used at authorization time) and fall back to env vars → rclone.conf → the
    built-in rclone public application only when it is missing."""

Tainted flow: 'TOKEN_FILE' from os.environ.get (line 63, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 141)May include surrounding context.

python
tok["access_token"] = nt["access_token"]
    tok["refresh_token"] = nt.get("refresh_token", tok["refresh_token"])
    tok["expires_in"] = nt.get("expires_in", 3600)
    with open(TOKEN_FILE, "w") as f:
        json.dump(tok, f, indent=2)
    os.chmod(TOKEN_FILE, 0o600)
    log("  ↻ token refreshed")

Tainted flow: 'MANIFEST' from os.environ.get (line 64, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 362)May include surrounding context.

python
def save_manifest(m):
    with open(MANIFEST, "w") as f:
        json.dump(m, f, indent=2, ensure_ascii=False)
    os.chmod(MANIFEST, 0o600)

Tainted flow: 'dest' from os.environ.get (line 371, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/graph_sync.py (reported line 372)May include surrounding context.

python
dest_dir = os.path.join(CACHE_DIR, sanitize(rel))
    os.makedirs(dest_dir, exist_ok=True)
    dest = os.path.join(dest_dir, sanitize(pid) + ".html")
    with open(dest, "w", encoding="utf-8") as f:
        f.write(html)
    return dest

Tainted flow: 'dest' from os.environ.get (line 582, credential/environment) → open (file write)

Medium
Category
Data Flow
Confidence
88% confidence
Finding

The output path is built from NOTECLAW-derived base paths plus manifest-controlled section names, and the code writes files without normalizing and enforcing that the final destination stays under STORE_DIR. If a malicious or corrupted manifest contains path traversal components in section names, the script could write markdown files outside the intended store/ tree.

Content

Scanner excerpt · scripts/html2md.py (reported line 583)May include surrounding context.

python
dest_dir = os.path.join(STORE_DIR, rel)
            os.makedirs(dest_dir, exist_ok=True)
            dest = os.path.join(dest_dir, fn)
            with open(dest, "w", encoding="utf-8") as f:
                f.write(md)
            written += 1
        # orphan cleanup: delete files inside this section's store that are not in

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/html2md.py (reported line 624)May include surrounding context.

python
# git check: not only must this be inside a repository, the repository root
    # must be the data directory itself (so that git add -A in a data directory
    # living inside a bigger git repository does not stage the outer files)
    top = subprocess.run(["git", "-C", NOTECLAW, "rev-parse", "--show-toplevel"],
                         capture_output=True, text=True)
    if top.returncode != 0:
        print("⚠ the current directory is not a git repository: md was written to store/, committing the snapshot was skipped")

Tainted flow: 'NOTECLAW' from os.environ.get (line 40, credential/environment) → subprocess.run (code execution)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/html2md.py (reported line 624)May include surrounding context.

python
# git check: not only must this be inside a repository, the repository root
    # must be the data directory itself (so that git add -A in a data directory
    # living inside a bigger git repository does not stage the outer files)
    top = subprocess.run(["git", "-C", NOTECLAW, "rev-parse", "--show-toplevel"],
                         capture_output=True, text=True)
    if top.returncode != 0:
        print("⚠ the current directory is not a git repository: md was written to store/, committing the snapshot was skipped")

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
90% confidence
Finding

The script automatically runs 'git add -A' in a directory selected from NOTECLAW_HOME or the current working directory, which can stage every tracked and untracked change in that repository. In a multi-user or automation context, an attacker who can influence NOTECLAW_HOME or repository contents could cause unintended files to be staged and later committed, creating a data exposure/integrity risk.

Content

Scanner excerpt · scripts/html2md.py (reported line 636)May include surrounding context.

python
print("  → run git init in the data directory (see SKILL.md), or cd into the data directory and commit store/ manually")
        sys.exit(1)

    subprocess.run(["git", "add", "-A"], cwd=NOTECLAW, check=True)
    diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=NOTECLAW)
    if diff.returncode == 0:
        print("store/ unchanged, skipping commit")

Tainted flow: 'NOTECLAW' from os.environ.get (line 40, credential/environment) → subprocess.run (code execution)

Medium
Category
Data Flow
Confidence
92% confidence
Finding

NOTECLAW is derived from an environment variable or cwd and is used as the working directory for 'git add -A', so an attacker who controls execution context can redirect staging into an unintended repository. That does not yield shell injection, but it can cause mass staging of unrelated or sensitive files and alter repository state unexpectedly.

Content

Scanner excerpt · scripts/html2md.py (reported line 636)May include surrounding context.

python
print("  → run git init in the data directory (see SKILL.md), or cd into the data directory and commit store/ manually")
        sys.exit(1)

    subprocess.run(["git", "add", "-A"], cwd=NOTECLAW, check=True)
    diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=NOTECLAW)
    if diff.returncode == 0:
        print("store/ unchanged, skipping commit")

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/html2md.py (reported line 637)May include surrounding context.

python
sys.exit(1)

    subprocess.run(["git", "add", "-A"], cwd=NOTECLAW, check=True)
    diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=NOTECLAW)
    if diff.returncode == 0:
        print("store/ unchanged, skipping commit")
        sys.exit(rc)

Tainted flow: 'NOTECLAW' from os.environ.get (line 40, credential/environment) → subprocess.run (code execution)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/html2md.py (reported line 637)May include surrounding context.

python
sys.exit(1)

    subprocess.run(["git", "add", "-A"], cwd=NOTECLAW, check=True)
    diff = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=NOTECLAW)
    if diff.returncode == 0:
        print("store/ unchanged, skipping commit")
        sys.exit(rc)

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/html2md.py (reported line 647)May include surrounding context.

python
# check the git identity: a missing user.name / user.email always makes commit
    # fail, so warn early in a friendly way
    def git_cfg(key):
        out = subprocess.run(["git", "-C", NOTECLAW, "config", key],
                             capture_output=True, text=True)
        return out.returncode == 0 and bool(out.stdout.strip())

Tainted flow: 'NOTECLAW' from os.environ.get (line 40, credential/environment) → subprocess.run (code execution)

Medium
Category
Data Flow
Confidence
65% confidence
Finding

Data from a source is assigned to a variable that is later passed to a sink, creating a variable-mediated taint flow.

Content

Scanner excerpt · scripts/html2md.py (reported line 647)May include surrounding context.

python
# check the git identity: a missing user.name / user.email always makes commit
    # fail, so warn early in a friendly way
    def git_cfg(key):
        out = subprocess.run(["git", "-C", NOTECLAW, "config", key],
                             capture_output=True, text=True)
        return out.returncode == 0 and bool(out.stdout.strip())

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
91% confidence
Finding

The script automatically commits all previously staged changes with a user-controlled commit message and in a repository path influenced by NOTECLAW_HOME/current directory. Combined with broad staging, this can persist unintended or sensitive files into repository history, making accidental disclosure harder to remediate.

Content

Scanner excerpt · scripts/html2md.py (reported line 656)May include surrounding context.

python
print("  → run first: git config user.name \"your name\" && git config user.email \"your email\"")
        print("  → then rerun this script to finish the commit; the md files are already in store/ and will not be lost")
        sys.exit(1)
    r = subprocess.run(["git", "commit", "-m", msg], cwd=NOTECLAW, capture_output=True, text=True)
    if r.returncode != 0:
        print(f"⚠ git commit failed: {r.stderr.strip() or r.stdout.strip()}")
        print("  → the md files are already on disk in store/; fix git and rerun this script to commit")

Tainted flow: 'NOTECLAW' from os.environ.get (line 40, credential/environment) → subprocess.run (code execution)

Medium
Category
Data Flow
Confidence
92% confidence
Finding

Using an environment-influenced working directory for 'git commit' can cause commits to be created in an unintended repository if execution context is manipulated. In conjunction with auto-staging, this can permanently record sensitive or unrelated content in version history.

Content

Scanner excerpt · scripts/html2md.py (reported line 656)May include surrounding context.

python
print("  → run first: git config user.name \"your name\" && git config user.email \"your email\"")
        print("  → then rerun this script to finish the commit; the md files are already in store/ and will not be lost")
        sys.exit(1)
    r = subprocess.run(["git", "commit", "-m", msg], cwd=NOTECLAW, capture_output=True, text=True)
    if r.returncode != 0:
        print(f"⚠ git commit failed: {r.stderr.strip() or r.stdout.strip()}")
        print("  → the md files are already on disk in store/; fix git and rerun this script to commit")

subprocess module call

Medium
Category
Dangerous Code Execution
Confidence
70% confidence
Finding

subprocess module calls execute external commands. Without careful input validation, this enables command injection.

Content

Scanner excerpt · scripts/notequery.py (reported line 424)May include surrounding context.

python
# ---------- content-level diff (side track: a git wrapper, on demand) ----------
def store_commits(n=1):
    """the last n commits that changed this notebook's store (newest → oldest)"""
    out = subprocess.run(["git", "-C", NOTECLAW, "log", f"-{n}", "--format=%H", "--",
                          _git_store_path()],
                         capture_output=True, text=True, encoding="utf-8", errors="replace")
    return [h for h in (out.stdout or "").split() if h]

Static analysis

No suspicious patterns detected.