skillguard-lint 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
skillguard/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """SkillGuard: The Security Scanner, Linter, and Quality Gate for AI Agent Skills.
2
+
3
+ Static analysis for SKILL.md files, catching safety risks, prompt injections,
4
+ token bloat, routing collisions, and structural anti-patterns before runtime.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "Eslam Elzamkan"
9
+ __license__ = "Apache-2.0"
10
+
11
+ from .rules import Diagnostic, Rule, Severity
12
+ from .scanner import SkillScanner
13
+ from .library import LibraryAuditor
14
+
15
+ __all__ = [
16
+ "Diagnostic",
17
+ "Rule",
18
+ "Severity",
19
+ "SkillScanner",
20
+ "LibraryAuditor",
21
+ "__version__",
22
+ ]
23
+
skillguard/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """Command-line interface for SkillGuard."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import List, Set
7
+
8
+ from . import __version__
9
+ from .formatter import format_github, format_json, format_terminal
10
+ from .library import LibraryAuditor
11
+ from .rules import Diagnostic, Severity, RULES
12
+ from .scaffold import create_skill_scaffold
13
+ from .scanner import SkillScanner
14
+
15
+
16
+ def parse_ignore_list(ignore_str: str) -> Set[str]:
17
+ if not ignore_str:
18
+ return set()
19
+ return {code.strip().upper() for code in ignore_str.split(",") if code.strip()}
20
+
21
+
22
+ def cmd_check(args) -> int:
23
+ target_path = Path(args.path)
24
+ if not target_path.exists():
25
+ print(f"Error: Path '{target_path}' does not exist.", file=sys.stderr)
26
+ return 1
27
+
28
+ ignored = parse_ignore_list(args.ignore)
29
+ scanner = SkillScanner(ignored_rules=ignored)
30
+
31
+ diagnostics: List[Diagnostic] = []
32
+ diagnostics.extend(scanner.scan_path(target_path))
33
+
34
+ # Also audit library-level checks if target is a directory and not disabled
35
+ if target_path.is_dir() and not getattr(args, "no_library", False):
36
+ auditor = LibraryAuditor(target_path, use_semantic=getattr(args, "semantic", False))
37
+ lib_diags = [d for d in auditor.audit_library() if d.rule.code not in ignored]
38
+ diagnostics.extend(lib_diags)
39
+
40
+ # Output formatting
41
+ if args.format == "json":
42
+ print(format_json(diagnostics))
43
+ elif args.format == "github":
44
+ print(format_github(diagnostics))
45
+ else:
46
+ print(format_terminal(diagnostics, verbose=args.verbose))
47
+
48
+ has_errors = any(d.severity == Severity.ERROR for d in diagnostics)
49
+ return 1 if has_errors else 0
50
+
51
+
52
+ def cmd_audit(args) -> int:
53
+ """Security-focused audit (SF1xx, SF2xx only)."""
54
+ target_path = Path(args.path)
55
+ if not target_path.exists():
56
+ print(f"Error: Path '{target_path}' does not exist.", file=sys.stderr)
57
+ return 1
58
+
59
+ ignored = parse_ignore_list(args.ignore)
60
+ scanner = SkillScanner(ignored_rules=ignored)
61
+
62
+ all_diags = scanner.scan_path(target_path)
63
+ # Filter to security & portability rules only
64
+ sec_diags = [d for d in all_diags if d.rule.code.startswith(("SF1", "SF2"))]
65
+
66
+ if args.format == "json":
67
+ print(format_json(sec_diags))
68
+ elif args.format == "github":
69
+ print(format_github(sec_diags))
70
+ else:
71
+ print(format_terminal(sec_diags, verbose=args.verbose))
72
+
73
+ has_errors = any(d.severity == Severity.ERROR for d in sec_diags)
74
+ return 1 if has_errors else 0
75
+
76
+
77
+ def cmd_dedupe(args) -> int:
78
+ """Multi-skill library collision and token bloat check."""
79
+ target_path = Path(args.path)
80
+ if not target_path.is_dir():
81
+ print(f"Error: Target '{target_path}' must be a directory containing skills.", file=sys.stderr)
82
+ return 1
83
+
84
+ ignored = parse_ignore_list(args.ignore)
85
+ auditor = LibraryAuditor(target_path, use_semantic=getattr(args, "semantic", False))
86
+ diagnostics = [d for d in auditor.audit_library() if d.rule.code not in ignored]
87
+
88
+ if args.format == "json":
89
+ print(format_json(diagnostics))
90
+ elif args.format == "github":
91
+ print(format_github(diagnostics))
92
+ else:
93
+ print(format_terminal(diagnostics, verbose=args.verbose))
94
+
95
+ has_errors = any(d.severity == Severity.ERROR for d in diagnostics)
96
+ return 1 if has_errors else 0
97
+
98
+
99
+ def cmd_init(args) -> int:
100
+ try:
101
+ path = create_skill_scaffold(args.name, Path(args.dir), description=args.description or "")
102
+ print(f"✓ Created new production-ready skill scaffold at:\n {path.resolve()}")
103
+ return 0
104
+ except Exception as e:
105
+ print(f"Error: {e}", file=sys.stderr)
106
+ return 1
107
+
108
+
109
+ def cmd_rules(args) -> int:
110
+ """List all available diagnostic rules."""
111
+ print(f"\nSkillGuard Diagnostic Rules (v{__version__}):\n")
112
+ print(f"{'Code':<8} {'Category':<12} {'Severity':<10} {'Name':<32} {'Summary'}")
113
+ print("-" * 90)
114
+ for code, rule in sorted(RULES.items()):
115
+ print(f"{rule.code:<8} {rule.category:<12} {rule.severity.value:<10} {rule.name:<32} {rule.summary}")
116
+ print("\nRun `skillguard check <path>` (or `skg check <path>`) to validate skills.\n")
117
+ return 0
118
+
119
+
120
+ def cmd_stats(args) -> int:
121
+ """Calculate token and size statistics for skills."""
122
+ target_path = Path(args.path)
123
+ auditor = LibraryAuditor(target_path)
124
+ skills = auditor.find_all_skills()
125
+
126
+ if not skills:
127
+ print("No skills found.")
128
+ return 0
129
+
130
+ print(f"\nSkill Library Statistics ({len(skills)} skills found):\n")
131
+ print(f"{'Skill Name':<30} {'Words':<10} {'Est. Tokens':<15} {'Status'}")
132
+ print("-" * 65)
133
+
134
+ total_words = 0
135
+ for sdir in skills:
136
+ skill_md = sdir / "SKILL.md"
137
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
138
+ words = len(text.split())
139
+ total_words += words
140
+ est_tokens = int(words * 1.33)
141
+ status = "Optimal" if 40 <= words <= 1600 else "Too Long" if words > 1600 else "Too Short"
142
+ print(f"{sdir.name:<30} {words:<10} ~{est_tokens:<14} {status}")
143
+
144
+ total_tokens = int(total_words * 1.33)
145
+ print("-" * 65)
146
+ print(f"{'Total Library Footprint':<30} {total_words:<10} ~{total_tokens:<14}\n")
147
+ return 0
148
+
149
+
150
+ def build_parser() -> argparse.ArgumentParser:
151
+ parser = argparse.ArgumentParser(
152
+ prog="skillguard",
153
+ description="SkillGuard: The Security Scanner, Linter, and Quality Gate for AI Agent Skills.",
154
+ )
155
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
156
+
157
+ subparsers = parser.add_subparsers(dest="command", required=True)
158
+
159
+ # check
160
+ p_check = subparsers.add_parser("check", help="Lint and security-scan skills")
161
+ p_check.add_argument("path", nargs="?", default=".", help="Path to SKILL.md or skills directory (default: current dir)")
162
+ p_check.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
163
+ p_check.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
164
+ p_check.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore (e.g. SF008,SF201)")
165
+ p_check.add_argument("--no-library", action="store_true", help="Skip multi-skill library checks")
166
+ p_check.add_argument("--semantic", action="store_true", help="Use dense vector embeddings for collision check (requires sentence-transformers)")
167
+ p_check.set_defaults(func=cmd_check)
168
+
169
+ # audit
170
+ p_audit = subparsers.add_parser("audit", help="Security and safety audit (destructive ops, data exfil, injection)")
171
+ p_audit.add_argument("path", nargs="?", default=".", help="Path to SKILL.md or skills directory")
172
+ p_audit.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
173
+ p_audit.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
174
+ p_audit.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore")
175
+ p_audit.set_defaults(func=cmd_audit)
176
+
177
+ # dedupe
178
+ p_dedupe = subparsers.add_parser("dedupe", help="Analyze routing collisions, trigger overlap, and library caps")
179
+ p_dedupe.add_argument("path", nargs="?", default=".", help="Path to skills directory")
180
+ p_dedupe.add_argument("--format", choices=["terminal", "json", "github"], default="terminal", help="Output format")
181
+ p_dedupe.add_argument("--verbose", "-v", action="store_true", help="Show remediation guidance")
182
+ p_dedupe.add_argument("--ignore", default="", help="Comma-separated rule codes to ignore")
183
+ p_dedupe.add_argument("--semantic", action="store_true", help="Use dense vector embeddings for collision check (requires sentence-transformers)")
184
+ p_dedupe.set_defaults(func=cmd_dedupe)
185
+
186
+ # init
187
+ p_init = subparsers.add_parser("init", help="Scaffold a new production-ready skill pack")
188
+ p_init.add_argument("name", help="Skill name (e.g. git-conflict-resolver)")
189
+ p_init.add_argument("--dir", default=".", help="Target directory to create skill in (default: current dir)")
190
+ p_init.add_argument("--description", default="", help="Skill description")
191
+ p_init.set_defaults(func=cmd_init)
192
+
193
+ # rules
194
+ p_rules = subparsers.add_parser("rules", help="List all diagnostic rules and codes")
195
+ p_rules.set_defaults(func=cmd_rules)
196
+
197
+ # stats
198
+ p_stats = subparsers.add_parser("stats", help="Token and word count statistics for skills")
199
+ p_stats.add_argument("path", nargs="?", default=".", help="Path to skills directory")
200
+ p_stats.set_defaults(func=cmd_stats)
201
+
202
+ return parser
203
+
204
+
205
+ def main() -> None:
206
+ parser = build_parser()
207
+ args = parser.parse_args()
208
+ exit_code = args.func(args)
209
+ sys.exit(exit_code)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
214
+
@@ -0,0 +1,117 @@
1
+ """Formatters for diagnostic output: Terminal (ANSI), JSON, and GitHub Actions."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import List
6
+
7
+ from .rules import Diagnostic, Severity
8
+
9
+ # ANSI color codes
10
+ RESET = "\033[0m"
11
+ BOLD = "\033[1m"
12
+ DIM = "\033[2m"
13
+ RED = "\033[31m"
14
+ YELLOW = "\033[33m"
15
+ CYAN = "\033[36m"
16
+ GREEN = "\033[32m"
17
+ MAGENTA = "\033[35m"
18
+
19
+
20
+ def supports_color() -> bool:
21
+ """Check if stdout supports ANSI color escape sequences."""
22
+ return sys.stdout.isatty()
23
+
24
+
25
+ def format_terminal(diagnostics: List[Diagnostic], verbose: bool = False) -> str:
26
+ use_color = supports_color()
27
+ lines = []
28
+
29
+ # Sort diagnostics by file, then line, then severity
30
+ sorted_diags = sorted(
31
+ diagnostics,
32
+ key=lambda d: (
33
+ d.file_path,
34
+ d.line or 0,
35
+ 0 if d.severity == Severity.ERROR else 1,
36
+ ),
37
+ )
38
+
39
+ current_file = None
40
+ for d in sorted_diags:
41
+ if d.file_path != current_file:
42
+ current_file = d.file_path
43
+ header = f"\n{BOLD}{current_file}{RESET}" if use_color else f"\n{current_file}"
44
+ lines.append(header)
45
+
46
+ # Severity badge
47
+ if d.severity == Severity.ERROR:
48
+ badge = f"{RED}{BOLD}error{RESET}" if use_color else "error"
49
+ elif d.severity == Severity.WARNING:
50
+ badge = f"{YELLOW}{BOLD}warning{RESET}" if use_color else "warning"
51
+ else:
52
+ badge = f"{CYAN}{BOLD}info{RESET}" if use_color else "info"
53
+
54
+ line_str = f":{d.line}" if d.line else ""
55
+ col_str = f":{d.column}" if d.column else ""
56
+ loc = f"{DIM}{line_str}{col_str}{RESET}" if use_color else f"{line_str}{col_str}"
57
+ code = f"{MAGENTA}[{d.rule.code}]{RESET}" if use_color else f"[{d.rule.code}]"
58
+
59
+ msg = d.formatted_message
60
+ lines.append(f" {loc:8} {badge:16} {code:8} {msg}")
61
+
62
+ if d.snippet:
63
+ snip = f" {DIM}> {d.snippet}{RESET}" if use_color else f" > {d.snippet}"
64
+ lines.append(snip)
65
+
66
+ if verbose and d.rule.remediation:
67
+ hint = f" {CYAN}Fix: {d.rule.remediation}{RESET}" if use_color else f" Fix: {d.rule.remediation}"
68
+ lines.append(hint)
69
+
70
+ # Summary
71
+ errors = sum(1 for d in diagnostics if d.severity == Severity.ERROR)
72
+ warnings = sum(1 for d in diagnostics if d.severity == Severity.WARNING)
73
+
74
+ summary_parts = []
75
+ if errors:
76
+ summary_parts.append(f"{RED}{BOLD}{errors} error{'s' if errors != 1 else ''}{RESET}" if use_color else f"{errors} errors")
77
+ if warnings:
78
+ summary_parts.append(f"{YELLOW}{BOLD}{warnings} warning{'s' if warnings != 1 else ''}{RESET}" if use_color else f"{warnings} warnings")
79
+
80
+ if not diagnostics:
81
+ return f"\n{GREEN}{BOLD}✓ All skill checks passed! Zero safety or quality defects detected.{RESET}\n" if use_color else "\n✓ All skill checks passed! Zero safety or quality defects detected.\n"
82
+
83
+ status = ", ".join(summary_parts)
84
+ return "\n".join(lines) + f"\n\nFound {status}.\n"
85
+
86
+
87
+ def format_json(diagnostics: List[Diagnostic]) -> str:
88
+ data = []
89
+ for d in diagnostics:
90
+ data.append({
91
+ "file": d.file_path,
92
+ "line": d.line,
93
+ "column": d.column,
94
+ "rule_code": d.rule.code,
95
+ "rule_name": d.rule.name,
96
+ "category": d.rule.category,
97
+ "severity": d.severity.value,
98
+ "message": d.formatted_message,
99
+ "snippet": d.snippet,
100
+ "remediation": d.rule.remediation,
101
+ })
102
+ return json.dumps(data, indent=2)
103
+
104
+
105
+ def format_github(diagnostics: List[Diagnostic]) -> str:
106
+ """GitHub Actions workflow commands for inline PR annotations."""
107
+ lines = []
108
+ for d in diagnostics:
109
+ cmd = "error" if d.severity == Severity.ERROR else "warning"
110
+ loc = f"file={d.file_path}"
111
+ if d.line:
112
+ loc += f",line={d.line}"
113
+ if d.column:
114
+ loc += f",col={d.column}"
115
+ lines.append(f"::{cmd} {loc}::[{d.rule.code}] {d.formatted_message}")
116
+ return "\n".join(lines)
117
+
skillguard/library.py ADDED
@@ -0,0 +1,157 @@
1
+ """Library auditor: cross-skill analysis, collision detection, and token budgeting."""
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+ from typing import Dict, List, Set, Tuple
7
+
8
+ from .rules import Diagnostic, Rule, RULES
9
+
10
+ STOPWORDS = {
11
+ "use", "when", "the", "a", "an", "for", "to", "of", "and", "or",
12
+ "not", "this", "is", "with", "on", "in", "it", "at", "by", "from",
13
+ "that", "as", "be", "do", "you", "agent", "skill"
14
+ }
15
+
16
+ LIBRARY_CAP = 8
17
+ COLLISION_JACCARD_THRESHOLD = 0.55
18
+
19
+
20
+ class LibraryAuditor:
21
+ """Performs library-wide analysis across a collection of skills.
22
+
23
+ Engine:
24
+ - Default: Fast, zero-dependency Lexical & Trigger Overlap Analysis
25
+ (Jaccard similarity on keyword tokens and n-grams).
26
+ - Optional: Dense vector semantic analysis if `use_semantic=True`
27
+ and `sentence-transformers` is installed.
28
+ """
29
+
30
+ def __init__(self, root_dir: Path, use_semantic: bool = False):
31
+ self.root_dir = Path(root_dir)
32
+ self.use_semantic = use_semantic
33
+
34
+ def find_all_skills(self) -> List[Path]:
35
+ """Find all skill directories containing a SKILL.md under root_dir."""
36
+ skill_dirs = []
37
+ if (self.root_dir / "SKILL.md").exists():
38
+ return [self.root_dir]
39
+ for p in sorted(self.root_dir.rglob("SKILL.md")):
40
+ # Ignore hidden or internal folders like .skillguard, .skill-forge, or _versions
41
+ if not any(part.startswith(".") or part.startswith("_") for part in p.parts):
42
+ skill_dirs.append(p.parent)
43
+ return skill_dirs
44
+
45
+ def tokenize_description(self, description: str) -> Set[str]:
46
+ words = re.findall(r"[a-z]{3,}", description.lower())
47
+ return {w for w in words if w not in STOPWORDS}
48
+
49
+ def audit_library(self) -> List[Diagnostic]:
50
+ diagnostics: List[Diagnostic] = []
51
+ skills = self.find_all_skills()
52
+
53
+ # SF302: Library size check (> 8 skills)
54
+ if len(skills) > LIBRARY_CAP:
55
+ diagnostics.append(
56
+ Diagnostic(
57
+ rule=RULES["SF302"],
58
+ file_path=str(self.root_dir),
59
+ message=f"Library contains {len(skills)} skills (recommended cap: {LIBRARY_CAP}). Over-accumulation dilutes agent lift.",
60
+ )
61
+ )
62
+
63
+ # Collect descriptions and metadata
64
+ descriptions: Dict[str, str] = {}
65
+ names: List[str] = []
66
+ domains: Dict[str, List[str]] = defaultdict(list)
67
+
68
+ for sdir in skills:
69
+ skill_md = sdir / "SKILL.md"
70
+ text = skill_md.read_text(encoding="utf-8", errors="replace")
71
+ desc_match = re.search(r"description:\s*(.+)", text)
72
+ name_match = re.search(r"name:\s*(.+)", text)
73
+
74
+ skill_name = name_match.group(1).strip() if name_match else sdir.name
75
+ desc_text = desc_match.group(1).strip() if desc_match else ""
76
+
77
+ descriptions[skill_name] = desc_text
78
+ names.append(skill_name)
79
+
80
+ # Guess domain from folder or name prefix
81
+ domain = sdir.parent.name if sdir.parent != self.root_dir else skill_name.split("-")[0]
82
+ domains[domain].append(skill_name)
83
+
84
+ # SF301: Description collisions / routing interference
85
+ if self.use_semantic:
86
+ try:
87
+ from sentence_transformers import SentenceTransformer, util
88
+ model = SentenceTransformer("all-MiniLM-L6-v2")
89
+ desc_texts = [descriptions[n] for n in names]
90
+ embeddings = model.encode(desc_texts, convert_to_tensor=True)
91
+ cosine_scores = util.cos_sim(embeddings, embeddings)
92
+
93
+ for i, a in enumerate(names):
94
+ for j in range(i + 1, len(names)):
95
+ b = names[j]
96
+ score = float(cosine_scores[i][j])
97
+ if score >= 0.75:
98
+ diagnostics.append(
99
+ Diagnostic(
100
+ rule=RULES["SF301"],
101
+ file_path=str(self.root_dir),
102
+ message=(
103
+ f"High semantic vector similarity ({round(score * 100)}%) between `{a}` and `{b}`. "
104
+ f"Overlapping intent causes routing confusion."
105
+ ),
106
+ )
107
+ )
108
+ except ImportError:
109
+ diagnostics.append(
110
+ Diagnostic(
111
+ rule=RULES["SF301"],
112
+ file_path=str(self.root_dir),
113
+ message="Notice: 'sentence-transformers' not installed for dense semantic analysis. Falling back to pure-Python lexical trigger overlap. Install with: pip install skillguard[semantic]",
114
+ )
115
+ )
116
+ self.use_semantic = False
117
+
118
+ if not self.use_semantic:
119
+ # Default: Pure-Python Lexical & Trigger Overlap Analysis (Jaccard similarity)
120
+ for i, a in enumerate(names):
121
+ tokens_a = self.tokenize_description(descriptions[a])
122
+ if not tokens_a:
123
+ continue
124
+ for b in names[i + 1:]:
125
+ tokens_b = self.tokenize_description(descriptions[b])
126
+ if not tokens_b:
127
+ continue
128
+ intersection = tokens_a & tokens_b
129
+ union = tokens_a | tokens_b
130
+ jaccard = len(intersection) / len(union) if union else 0.0
131
+
132
+ if jaccard >= COLLISION_JACCARD_THRESHOLD:
133
+ shared_keywords = ", ".join(sorted(intersection)[:5])
134
+ diagnostics.append(
135
+ Diagnostic(
136
+ rule=RULES["SF301"],
137
+ file_path=str(self.root_dir),
138
+ message=(
139
+ f"High lexical trigger overlap ({round(jaccard * 100)}%) between `{a}` and `{b}`. "
140
+ f"Shared trigger tokens: [{shared_keywords}]. Causes routing confusion."
141
+ ),
142
+ )
143
+ )
144
+
145
+ # SF303: Domain duplicate skills
146
+ for domain, d_skills in domains.items():
147
+ if len(d_skills) > 1 and domain not in ("skills", "tasks", "src"):
148
+ diagnostics.append(
149
+ Diagnostic(
150
+ rule=RULES["SF303"],
151
+ file_path=str(self.root_dir),
152
+ message=f"Multiple skills found in domain '{domain}': {', '.join(d_skills)}. Consider consolidating into one skill.",
153
+ )
154
+ )
155
+
156
+ return diagnostics
157
+