Coverage for src / agent / skills / security.py: 100%
47 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Security validation for skill subsystem.
17This module provides security functions for skill name sanitization,
18path validation, and trust management.
19"""
21import re
22from pathlib import Path
24from git import Repo
26from agent.skills.errors import SkillManifestError, SkillSecurityError
29def sanitize_skill_name(name: str) -> str:
30 """Validate skill name for security.
32 Ensures skill names are safe to use in filesystem paths and prevent
33 directory traversal attacks.
35 Args:
36 name: Skill name to validate
38 Returns:
39 The validated name (unchanged if valid)
41 Raises:
42 SkillSecurityError: If name contains invalid characters or patterns
44 Examples:
45 >>> sanitize_skill_name("kalshi-markets")
46 'kalshi-markets'
47 >>> sanitize_skill_name("../etc/passwd")
48 Traceback (most recent call last):
49 ...
50 SkillSecurityError: Invalid skill name: '../etc/passwd'
51 """
52 # Reserved names
53 reserved = {".", "..", "~", "__pycache__", ""}
54 if name in reserved:
55 raise SkillSecurityError(f"Reserved skill name: '{name}'")
57 # Reject path traversal patterns (check before regex)
58 if ".." in name or name.startswith("/") or name.startswith("\\"):
59 raise SkillSecurityError(f"Invalid skill name: '{name}' (path traversal detected)")
61 # Reject spaces (check before regex for clearer error)
62 if " " in name:
63 raise SkillSecurityError(f"Invalid skill name: '{name}' (spaces not allowed)")
65 # Must be alphanumeric + hyphens/underscores, 1-64 chars
66 if not re.match(r"^[a-zA-Z0-9_-]{1,64}$", name):
67 raise SkillSecurityError(
68 f"Invalid skill name: '{name}' "
69 "(must be alphanumeric with hyphens/underscores, 1-64 chars)"
70 )
72 return name
75def normalize_skill_name(name: str) -> str:
76 """Normalize skill name to canonical form.
78 Converts to lowercase and replaces underscores with hyphens for
79 case-insensitive, format-agnostic matching.
81 Args:
82 name: Skill name to normalize
84 Returns:
85 Canonical skill name (lowercase, hyphens)
87 Examples:
88 >>> normalize_skill_name("Kalshi-Markets")
89 'kalshi-markets'
90 >>> normalize_skill_name("My_Skill_Name")
91 'my-skill-name'
92 >>> normalize_skill_name("skill_123")
93 'skill-123'
94 """
95 # First validate (will raise if invalid)
96 sanitize_skill_name(name)
98 # Convert to lowercase and replace underscores with hyphens
99 canonical = name.lower().replace("_", "-")
101 return canonical
104def normalize_script_name(name: str) -> str:
105 """Normalize script name to canonical form.
107 Accepts both "status" and "status.py" formats, always returns with .py extension.
109 Args:
110 name: Script name with or without .py extension
112 Returns:
113 Canonical script name (lowercase, .py extension)
115 Examples:
116 >>> normalize_script_name("status")
117 'status.py'
118 >>> normalize_script_name("Status.py")
119 'status.py'
120 >>> normalize_script_name("markets")
121 'markets.py'
122 """
123 # Convert to lowercase
124 name = name.lower()
126 # Add .py extension if missing
127 if not name.endswith(".py"):
128 name = f"{name}.py"
130 return name
133def confirm_untrusted_install(skill_name: str, git_url: str) -> bool:
134 """Prompt user for confirmation to install untrusted skill.
136 Args:
137 skill_name: Name of the skill to install
138 git_url: Git repository URL
140 Returns:
141 True if user confirms, False otherwise
143 Note:
144 This is a placeholder for Phase 2. In Phase 1, bundled skills
145 are automatically trusted.
146 """
147 # Phase 2 implementation: Interactive prompt
148 # For now, return True for bundled skills (no git_url)
149 # and False for git installs (require explicit --trusted flag)
150 if git_url is None:
151 return True # Bundled skills are trusted
153 # Phase 2: Use rich.prompt.Confirm for interactive confirmation
154 # For now, require explicit trust flag in CLI
155 return False
158def pin_commit_sha(repo_path: Path) -> str:
159 """Get current commit SHA from a git repository.
161 Args:
162 repo_path: Path to git repository
164 Returns:
165 Current commit SHA (40-character hex string)
167 Raises:
168 SkillSecurityError: If repository is invalid or not a git repo
170 Examples:
171 >>> repo_path = Path("/path/to/skill")
172 >>> sha = pin_commit_sha(repo_path) # doctest: +SKIP
173 >>> len(sha) # doctest: +SKIP
174 40
175 """
176 try:
177 repo = Repo(repo_path)
178 if repo.head.is_detached:
179 # Detached HEAD, use current commit
180 return str(repo.head.commit)
181 else:
182 # On a branch, use branch head
183 return str(repo.head.commit)
184 except Exception as e:
185 raise SkillSecurityError(f"Failed to get commit SHA from {repo_path}: {e}")
188def validate_manifest(manifest_path: Path) -> None:
189 """Validate SKILL.md manifest file.
191 Checks that:
192 - File exists
193 - Is UTF-8 encoded
194 - Has valid YAML front matter
195 - Contains required fields
197 Args:
198 manifest_path: Path to SKILL.md file
200 Raises:
201 SkillManifestError: If manifest is invalid
203 Examples:
204 >>> from pathlib import Path
205 >>> manifest_path = Path("/path/to/SKILL.md")
206 >>> validate_manifest(manifest_path) # doctest: +SKIP
207 """
208 if not manifest_path.exists():
209 raise SkillManifestError(f"SKILL.md not found at {manifest_path}")
211 if not manifest_path.is_file():
212 raise SkillManifestError(f"SKILL.md is not a file: {manifest_path}")
214 # Check UTF-8 encoding
215 try:
216 content = manifest_path.read_text(encoding="utf-8")
217 except UnicodeDecodeError:
218 raise SkillManifestError(f"SKILL.md must be UTF-8 encoded: {manifest_path}")
220 # Check YAML front matter exists
221 if not content.startswith("---"):
222 raise SkillManifestError(f"SKILL.md must start with YAML front matter: {manifest_path}")
224 # Further validation happens in manifest.parse_skill_manifest()
225 # This is just a quick check for file existence and encoding