Skip to content

Commit 75947ae

Browse files
frontsidebusclaudevineethsai7
authored
feat: allow --lenient to scan skills without SKILL.md (#63)
* feat: allow --lenient to scan skills without SKILL.md and fix jq pipe false positive When --lenient is set and SKILL.md is absent, the loader now falls back to scanning .md files in the directory as instruction bodies. This enables scanning non-Codex/Cursor skill formats (Claude Code commands, flat markdown repos) without requiring a shim. Also adds --skill-file flag for specifying a custom metadata filename, updates scan-all discovery to find .md-containing directories in lenient mode, and fixes the pipeline taint analyzer splitting pipes inside quoted strings (e.g. jq '.events[] | {source}' was incorrectly flagged as a bash source invocation). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update documentation for --lenient fallback, --skill-file, and jq fix Update README, quick-start, CLI usage, CLI reference, scanning pipeline, Python SDK, and FEATURE.md to document: - --lenient now falls back to .md files when SKILL.md is absent - New --skill-file flag for custom metadata filenames - Quote-aware pipe splitting in the pipeline analyzer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Vineeth Sai Narajala <vnarajal@cisco.com>
1 parent 1cf2325 commit 75947ae

12 files changed

Lines changed: 583 additions & 33 deletions

File tree

FEATURE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Post-processing includes:
7575
### 3. Pipeline Analyzer
7676

7777
- Parses shell command pipelines and classifies risk
78+
- Quote-aware pipe splitting (avoids false positives on `jq` expressions and other quoted `|` characters)
7879
- Detects fetch-and-execute and sensitive source-to-sink chains
7980
- Uses `command_safety`, `pipeline`, and `sensitive_files` policy knobs
8081

@@ -165,6 +166,7 @@ Implemented in `skill_scanner/core/scan_policy.py` with built-ins in `skill_scan
165166
- VirusTotal/AI Defense keys: `--vt-api-key`, `--aidefense-api-key`, `--aidefense-api-url`
166167
- Output: `--format`, `--output`, `--detailed`, `--compact`, `--fail-on-findings`, `--fail-on-severity`
167168
- Multi-skill: `--recursive`, `--check-overlap`
169+
- Flexibility: `--lenient` (scan without `SKILL.md`), `--skill-file` (custom metadata filename)
168170

169171
## API Server Features
170172

README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A best-effort security scanner for AI Agent Skills that detects prompt injection
1313

1414
> **Important:** This scanner provides best-effort detection, not comprehensive or complete coverage. A scan that returns no findings does not guarantee that a skill is free of all threats. See [Scope and Limitations](#scope-and-limitations) below.
1515
16-
Supports [OpenAI Codex Skills](https://openai.github.io/codex/) and [Cursor Agent Skills](https://docs.cursor.com/context/rules) formats following the [Agent Skills specification](https://agentskills.io).
16+
Supports [OpenAI Codex Skills](https://openai.github.io/codex/) and [Cursor Agent Skills](https://docs.cursor.com/context/rules) formats following the [Agent Skills specification](https://agentskills.io). With `--lenient`, also scans non-standard formats such as Claude Code `.claude/commands/*.md` and flat markdown skill repos.
1717

1818
---
1919

@@ -154,6 +154,13 @@ skill-scanner scan-all /path/to/skills --recursive --check-overlap
154154
skill-scanner scan /path/to/skill --lenient
155155
skill-scanner scan-all /path/to/skills --recursive --lenient
156156

157+
# Lenient mode with non-standard skill formats (no SKILL.md required)
158+
skill-scanner scan .claude/commands/deploy --lenient
159+
skill-scanner scan-all .claude/commands --recursive --lenient
160+
161+
# Use a custom metadata filename instead of SKILL.md
162+
skill-scanner scan /path/to/skill --skill-file README.md
163+
157164
# CI/CD: Fail build if threats found
158165
skill-scanner scan-all ./skills --fail-on-severity high --format sarif --output results.sarif
159166

@@ -252,7 +259,8 @@ if not result.is_safe:
252259
| `--custom-rules PATH` | Use custom YARA rules from directory |
253260
| `--taxonomy PATH` | Load custom taxonomy profile (JSON/YAML) for this run |
254261
| `--threat-mapping PATH` | Load custom scanner threat mapping profile (JSON) for this run |
255-
| `--lenient` | Tolerate malformed skills (coerce bad fields, fill defaults) instead of failing |
262+
| `--lenient` | Tolerate malformed skills (coerce bad fields, fill defaults) instead of failing. When `SKILL.md` is absent, falls back to scanning `.md` files in the directory |
263+
| `--skill-file FILENAME` | Custom metadata filename to use instead of `SKILL.md` (e.g. `README.md`) |
256264
| `--check-overlap` | (`scan-all`) Enable cross-skill description overlap checks |
257265

258266
| Command | Description |

docs/architecture/scanning-pipeline.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ flowchart TD
5858
## Stage 1: Load and Pre-process
5959

6060
1. **Load skill package** via `SkillLoader`:
61-
- Validate skill directory and `SKILL.md`
61+
- Validate skill directory and locate metadata file (`SKILL.md` by default, or `--skill-file`)
62+
- When `--lenient` is set and no `SKILL.md` exists, fall back to scanning `.md` files in the directory as instruction bodies (supports non-Codex/Cursor formats such as Claude Code commands)
6263
- Parse frontmatter (name, description, metadata)
6364
- Discover files recursively (excluding `.git` internals)
6465
- Classify file types (python, bash, markdown, binary, other)

docs/getting-started/quick-start.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,17 @@ skill-scanner scan-all /path/to/skills --check-overlap
200200

201201
### Lenient Mode
202202

203-
Tolerate malformed skills (missing fields, non-string descriptions) instead of failing:
203+
Tolerate malformed skills (missing fields, non-string descriptions) instead of failing. When `SKILL.md` is absent, lenient mode falls back to scanning `.md` files in the directory as instruction bodies — enabling support for non-Codex/Cursor formats such as Claude Code `.claude/commands/*.md`:
204204

205205
```bash
206206
skill-scanner scan /path/to/skill --lenient
207207
skill-scanner scan-all /path/to/skills --recursive --lenient
208+
209+
# Scan a Claude Code commands directory (no SKILL.md)
210+
skill-scanner scan .claude/commands/deploy --lenient
211+
212+
# Use a custom metadata filename instead of SKILL.md
213+
skill-scanner scan /path/to/skill --skill-file README.md
208214
```
209215

210216
### Pre-commit Hook

docs/reference/cli-command-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Flags shared by `scan` and `scan-all`:
3535
| `--enable-meta` | off | Enable the meta (cross-correlation) analyzer |
3636
| `--fail-on-findings` | off | Exit non-zero if critical or high findings are reported; equivalent to `--fail-on-severity high` (CI gate) |
3737
| `--fail-on-severity LEVEL` | off | Exit non-zero if findings at or above LEVEL exist (critical, high, medium, low, info) |
38-
| `--lenient` | off | Tolerate malformed skills: coerce bad fields, fill defaults, and continue instead of failing |
38+
| `--lenient` | off | Tolerate malformed skills: coerce bad fields, fill defaults, and continue instead of failing. When `SKILL.md` is absent, falls back to scanning `.md` files in the directory |
39+
| `--skill-file FILENAME` | `SKILL.md` | Custom metadata filename to use instead of `SKILL.md` |
3940
| `--detailed` | off | Include full evidence in output |
4041
| `--compact` | off | Minimize output (JSON: no pretty-print) |
4142
| `--verbose` | off | Verbose logging |

docs/user-guide/cli-usage.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ skill-scanner scan ./my-skill --policy strict
5959
skill-scanner scan ./my-skill --policy ./my-org-policy.yaml
6060
```
6161

62+
### Scanning non-standard skill formats
63+
64+
Use `--lenient` to scan skills that don't follow the Codex/Cursor `SKILL.md` convention (e.g. Claude Code `.claude/commands/*.md`):
65+
66+
```bash
67+
# Scan a directory with .md files but no SKILL.md
68+
skill-scanner scan .claude/commands/deploy --lenient
69+
70+
# Discover all .md-containing directories under a path
71+
skill-scanner scan-all .claude/commands --recursive --lenient
72+
73+
# Use a custom metadata file instead of SKILL.md
74+
skill-scanner scan ./my-skill --skill-file README.md
75+
```
76+
6277
## Choosing Analyzers
6378

6479
Not sure which flags to use? Pick the row that matches your situation:

docs/user-guide/python-sdk.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,31 @@ When `analyzers` is `None`, the scanner builds the default core analyzer set (st
3232

3333
## Instance Methods
3434

35-
### `scan_skill(skill_directory, *, lenient=False) → ScanResult`
35+
### `scan_skill(skill_directory, *, lenient=False, skill_file=None) → ScanResult`
3636

37-
Scan a single skill package directory. Pass `lenient=True` to coerce malformed manifests instead of raising an error.
37+
Scan a single skill package directory. Pass `lenient=True` to coerce malformed manifests instead of raising an error. When `lenient=True` and no `SKILL.md` exists, the loader falls back to scanning `.md` files in the directory. Pass `skill_file` to use a custom metadata filename (e.g. `"README.md"`).
3838

3939
```python
4040
result = scanner.scan_skill("/path/to/skill")
41+
42+
# Scan a directory without SKILL.md (e.g. Claude Code commands)
43+
result = scanner.scan_skill(".claude/commands/deploy", lenient=True)
44+
45+
# Use a custom metadata file
46+
result = scanner.scan_skill("/path/to/skill", skill_file="README.md")
4147
```
4248

43-
### `scan_directory(skills_directory, recursive=False, check_overlap=False, *, lenient=False) → Report`
49+
### `scan_directory(skills_directory, recursive=False, check_overlap=False, *, lenient=False, skill_file=None) → Report`
4450

45-
Scan all skill packages in a directory.
51+
Scan all skill packages in a directory. When `lenient=True`, directories containing `.md` files (but no `SKILL.md`) are also discovered as candidate skills.
4652

4753
```python
4854
report = scanner.scan_directory("/path/to/skills", recursive=True)
4955
print(report.total_skills_scanned)
5056
print(report.total_findings)
57+
58+
# Discover and scan non-standard skill formats
59+
report = scanner.scan_directory(".claude/commands", recursive=True, lenient=True)
5160
```
5261

5362
### `add_analyzer(analyzer)`

skill_scanner/cli/cli.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,9 +350,10 @@ def scan_command(args: argparse.Namespace) -> int:
350350

351351
scanner = SkillScanner(analyzers=analyzers, policy=policy)
352352
lenient = getattr(args, "lenient", False)
353+
skill_file = getattr(args, "skill_file", None)
353354

354355
try:
355-
result = scanner.scan_skill(skill_dir, lenient=lenient)
356+
result = scanner.scan_skill(skill_dir, lenient=lenient, skill_file=skill_file)
356357

357358
# Meta-analysis
358359
if meta_analyzer and result.findings and apply_meta_analysis_to_results is not None:
@@ -437,11 +438,16 @@ def scan_all_command(args: argparse.Namespace) -> int:
437438
scanner = SkillScanner(analyzers=analyzers, policy=policy)
438439

439440
lenient = getattr(args, "lenient", False)
441+
skill_file = getattr(args, "skill_file", None)
440442

441443
try:
442444
check_overlap = getattr(args, "check_overlap", False)
443445
report = scanner.scan_directory(
444-
skills_dir, recursive=args.recursive, check_overlap=check_overlap, lenient=lenient
446+
skills_dir,
447+
recursive=args.recursive,
448+
check_overlap=check_overlap,
449+
lenient=lenient,
450+
skill_file=skill_file,
445451
)
446452

447453
if report.total_skills_scanned == 0:
@@ -769,7 +775,16 @@ def _add_common_scan_flags(parser: argparse.ArgumentParser) -> None:
769775
parser.add_argument(
770776
"--lenient",
771777
action="store_true",
772-
help="Tolerate malformed skills: coerce bad fields, fill defaults, and continue instead of failing",
778+
help=(
779+
"Tolerate malformed skills: coerce bad fields, fill defaults, and continue instead of failing. "
780+
"When SKILL.md is absent, falls back to scanning .md files in the directory as instruction bodies "
781+
"(supports non-Codex/Cursor formats such as Claude Code commands)."
782+
),
783+
)
784+
parser.add_argument(
785+
"--skill-file",
786+
metavar="FILENAME",
787+
help="Custom metadata filename to use instead of SKILL.md (e.g. README.md)",
773788
)
774789
parser.add_argument(
775790
"--custom-rules",

skill_scanner/core/analyzers/pipeline_analyzer.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,51 @@ def _extract_pipelines(self, content: str, source_file: str) -> list[PipelineCha
249249

250250
return pipelines
251251

252+
@staticmethod
253+
def _split_pipeline(raw: str) -> list[str]:
254+
"""Split a pipeline string by ``|`` while respecting quotes.
255+
256+
Pipes inside single- or double-quoted strings (e.g. inside ``jq``
257+
expressions) are **not** treated as shell pipe operators.
258+
"""
259+
parts: list[str] = []
260+
current: list[str] = []
261+
in_single = False
262+
in_double = False
263+
i = 0
264+
length = len(raw)
265+
while i < length:
266+
ch = raw[i]
267+
if ch == "'" and not in_double:
268+
in_single = not in_single
269+
current.append(ch)
270+
elif ch == '"' and not in_single:
271+
in_double = not in_double
272+
current.append(ch)
273+
elif ch == "\\" and i + 1 < length:
274+
current.append(ch)
275+
current.append(raw[i + 1])
276+
i += 1
277+
elif ch == "|" and not in_single and not in_double:
278+
# Ignore || (logical OR)
279+
if i + 1 < length and raw[i + 1] == "|":
280+
current.append("||")
281+
i += 1
282+
else:
283+
parts.append("".join(current).strip())
284+
current = []
285+
else:
286+
current.append(ch)
287+
i += 1
288+
remainder = "".join(current).strip()
289+
if remainder:
290+
parts.append(remainder)
291+
return parts
292+
252293
def _parse_pipeline(self, raw: str, source_file: str, line_number: int) -> PipelineChain | None:
253294
"""Parse a pipeline string into a chain of CommandNodes."""
254-
# Split by pipe, but not by ||
255-
parts = re.split(r"\s*\|\s*(?!\|)", raw)
295+
# Split by pipe, respecting quotes (fixes jq expression false positives)
296+
parts = self._split_pipeline(raw)
256297
if len(parts) < 2:
257298
return None
258299

skill_scanner/core/loader.py

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,26 @@ def __init__(self, max_file_size_mb: int = 10, *, max_file_size_bytes: int | Non
5656
else:
5757
self.max_file_size_bytes = max_file_size_mb * 1024 * 1024
5858

59-
def load_skill(self, skill_directory: str | Path, *, lenient: bool = False) -> Skill:
59+
def load_skill(
60+
self,
61+
skill_directory: str | Path,
62+
*,
63+
lenient: bool = False,
64+
skill_file: str | None = None,
65+
) -> Skill:
6066
"""
6167
Load a skill package from a directory.
6268
6369
Args:
6470
skill_directory: Path to the skill directory
6571
lenient: When True, tolerate missing/malformed fields and return
6672
a best-effort Skill instead of raising ``SkillLoadError``.
73+
When ``SKILL.md`` is absent and *lenient* is True, the loader
74+
falls back to scanning ``.md`` files in the directory as
75+
instruction bodies (supports non-Codex/Cursor formats such as
76+
Claude Code ``.claude/commands/*.md``).
77+
skill_file: Optional custom metadata filename to use instead of
78+
``SKILL.md`` (e.g. ``"README.md"``).
6779
6880
Returns:
6981
Parsed Skill object
@@ -80,14 +92,25 @@ def load_skill(self, skill_directory: str | Path, *, lenient: bool = False) -> S
8092
if not skill_directory.is_dir():
8193
raise SkillLoadError(f"Path is not a directory: {skill_directory}")
8294

83-
# Find SKILL.md
84-
skill_md_path = skill_directory / "SKILL.md"
85-
if not skill_md_path.exists():
95+
# Find the skill metadata file
96+
if skill_file:
97+
skill_md_path = skill_directory / skill_file
98+
if not skill_md_path.exists():
99+
raise SkillLoadError(f"{skill_file} not found in {skill_directory}")
100+
else:
101+
skill_md_path = skill_directory / "SKILL.md"
102+
103+
if skill_md_path.exists():
104+
# Standard path: parse the metadata file
105+
manifest, instruction_body = self._parse_skill_md(skill_md_path, lenient=lenient)
106+
elif lenient:
107+
# Lenient fallback: no SKILL.md, synthesize from .md files in the directory
108+
skill_md_path, manifest, instruction_body = self._synthesize_from_md_files(
109+
skill_directory
110+
)
111+
else:
86112
raise SkillLoadError(f"SKILL.md not found in {skill_directory}")
87113

88-
# Parse SKILL.md
89-
manifest, instruction_body = self._parse_skill_md(skill_md_path, lenient=lenient)
90-
91114
# Discover all files in the skill package
92115
files = self._discover_files(skill_directory)
93116

@@ -103,6 +126,60 @@ def load_skill(self, skill_directory: str | Path, *, lenient: bool = False) -> S
103126
referenced_files=referenced_files,
104127
)
105128

129+
def _synthesize_from_md_files(self, skill_directory: Path) -> tuple[Path, SkillManifest, str]:
130+
"""Synthesize a Skill from ``.md`` files when ``SKILL.md`` is absent.
131+
132+
Scans the directory for markdown files, concatenates their content as the
133+
instruction body, and builds a best-effort manifest from the directory name.
134+
135+
Returns:
136+
Tuple of (primary_md_path, SkillManifest, instruction_body)
137+
138+
Raises:
139+
SkillLoadError: If no ``.md`` files are found in the directory.
140+
"""
141+
md_files = sorted(skill_directory.glob("*.md"))
142+
if not md_files:
143+
raise SkillLoadError(
144+
f"No SKILL.md and no .md files found in {skill_directory} "
145+
f"(lenient mode requires at least one markdown file)"
146+
)
147+
148+
logger.warning(
149+
"SKILL.md not found in %s; falling back to %d .md file(s) (lenient mode)",
150+
skill_directory,
151+
len(md_files),
152+
)
153+
154+
# Use the first .md file as the primary path
155+
primary_md = md_files[0]
156+
157+
# Try to parse frontmatter from the primary file
158+
try:
159+
manifest, body = self._parse_skill_md(primary_md, lenient=True)
160+
except SkillLoadError:
161+
# If even lenient parsing fails, use raw content
162+
try:
163+
body = primary_md.read_text(encoding="utf-8")
164+
except (OSError, UnicodeDecodeError):
165+
body = ""
166+
manifest = SkillManifest(
167+
name=skill_directory.name,
168+
description="(no description)",
169+
)
170+
171+
# Append content from remaining .md files
172+
extra_bodies: list[str] = []
173+
for md_file in md_files[1:]:
174+
try:
175+
extra_bodies.append(md_file.read_text(encoding="utf-8"))
176+
except (OSError, UnicodeDecodeError):
177+
continue
178+
if extra_bodies:
179+
body = body + "\n\n" + "\n\n".join(extra_bodies)
180+
181+
return primary_md, manifest, body
182+
106183
def _parse_skill_md(self, skill_md_path: Path, *, lenient: bool = False) -> tuple[SkillManifest, str]:
107184
"""
108185
Parse SKILL.md file with YAML frontmatter.

0 commit comments

Comments
 (0)