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

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. 

14 

15"""Security validation for skill subsystem. 

16 

17This module provides security functions for skill name sanitization, 

18path validation, and trust management. 

19""" 

20 

21import re 

22from pathlib import Path 

23 

24from git import Repo 

25 

26from agent.skills.errors import SkillManifestError, SkillSecurityError 

27 

28 

29def sanitize_skill_name(name: str) -> str: 

30 """Validate skill name for security. 

31 

32 Ensures skill names are safe to use in filesystem paths and prevent 

33 directory traversal attacks. 

34 

35 Args: 

36 name: Skill name to validate 

37 

38 Returns: 

39 The validated name (unchanged if valid) 

40 

41 Raises: 

42 SkillSecurityError: If name contains invalid characters or patterns 

43 

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}'") 

56 

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)") 

60 

61 # Reject spaces (check before regex for clearer error) 

62 if " " in name: 

63 raise SkillSecurityError(f"Invalid skill name: '{name}' (spaces not allowed)") 

64 

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 ) 

71 

72 return name 

73 

74 

75def normalize_skill_name(name: str) -> str: 

76 """Normalize skill name to canonical form. 

77 

78 Converts to lowercase and replaces underscores with hyphens for 

79 case-insensitive, format-agnostic matching. 

80 

81 Args: 

82 name: Skill name to normalize 

83 

84 Returns: 

85 Canonical skill name (lowercase, hyphens) 

86 

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) 

97 

98 # Convert to lowercase and replace underscores with hyphens 

99 canonical = name.lower().replace("_", "-") 

100 

101 return canonical 

102 

103 

104def normalize_script_name(name: str) -> str: 

105 """Normalize script name to canonical form. 

106 

107 Accepts both "status" and "status.py" formats, always returns with .py extension. 

108 

109 Args: 

110 name: Script name with or without .py extension 

111 

112 Returns: 

113 Canonical script name (lowercase, .py extension) 

114 

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() 

125 

126 # Add .py extension if missing 

127 if not name.endswith(".py"): 

128 name = f"{name}.py" 

129 

130 return name 

131 

132 

133def confirm_untrusted_install(skill_name: str, git_url: str) -> bool: 

134 """Prompt user for confirmation to install untrusted skill. 

135 

136 Args: 

137 skill_name: Name of the skill to install 

138 git_url: Git repository URL 

139 

140 Returns: 

141 True if user confirms, False otherwise 

142 

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 

152 

153 # Phase 2: Use rich.prompt.Confirm for interactive confirmation 

154 # For now, require explicit trust flag in CLI 

155 return False 

156 

157 

158def pin_commit_sha(repo_path: Path) -> str: 

159 """Get current commit SHA from a git repository. 

160 

161 Args: 

162 repo_path: Path to git repository 

163 

164 Returns: 

165 Current commit SHA (40-character hex string) 

166 

167 Raises: 

168 SkillSecurityError: If repository is invalid or not a git repo 

169 

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}") 

186 

187 

188def validate_manifest(manifest_path: Path) -> None: 

189 """Validate SKILL.md manifest file. 

190 

191 Checks that: 

192 - File exists 

193 - Is UTF-8 encoded 

194 - Has valid YAML front matter 

195 - Contains required fields 

196 

197 Args: 

198 manifest_path: Path to SKILL.md file 

199 

200 Raises: 

201 SkillManifestError: If manifest is invalid 

202 

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}") 

210 

211 if not manifest_path.is_file(): 

212 raise SkillManifestError(f"SKILL.md is not a file: {manifest_path}") 

213 

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}") 

219 

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}") 

223 

224 # Further validation happens in manifest.parse_skill_manifest() 

225 # This is just a quick check for file existence and encoding