Coverage for src/local_deep_research/security/file_write_verifier.py: 96%

63 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Security module for verified file write operations. 

2 

3This module ensures that file writes only occur when explicitly allowed by configuration, 

4maintaining the encryption-at-rest security model. 

5""" 

6 

7import json 

8import os 

9from pathlib import Path 

10from typing import Any 

11 

12from loguru import logger 

13 

14# Platforms without O_NOFOLLOW retain their normal symlink-following behavior. 

15_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) 

16 

17 

18def _open_nofollow(path: str, flags: int) -> int: 

19 """Add supported leaf-symlink refusal to Python's normal open flags.""" 

20 return os.open(path, flags | _O_NOFOLLOW, 0o666) 

21 

22 

23def _open_nofollow_with_permissions( 

24 path: str, flags: int, permissions: int 

25) -> int: 

26 """Tighten an existing file before truncation, or create it privately.""" 

27 truncate = bool(flags & os.O_TRUNC) 

28 fd = os.open(path, (flags & ~os.O_TRUNC) | _O_NOFOLLOW, permissions) 

29 try: 

30 os.fchmod(fd, permissions) 

31 if truncate: 31 ↛ 36line 31 didn't jump to line 36 because the condition on line 31 was always true

32 os.ftruncate(fd, 0) 

33 except BaseException: 

34 os.close(fd) 

35 raise 

36 return fd 

37 

38 

39# Keys that should never be written to disk in clear text 

40SENSITIVE_KEYS = frozenset( 

41 { 

42 "password", 

43 "api_key", 

44 "apikey", 

45 "api-key", 

46 "secret", 

47 "secret_key", 

48 "secretkey", 

49 "token", 

50 "access_token", 

51 "refresh_token", 

52 "private_key", 

53 "privatekey", 

54 "credentials", 

55 "auth", 

56 "authorization", 

57 } 

58) 

59 

60 

61def _sanitize_sensitive_data(data: Any) -> Any: 

62 """Remove sensitive keys from data before writing to disk. 

63 

64 Args: 

65 data: Data to sanitize (dict, list, or primitive) 

66 

67 Returns: 

68 Sanitized copy of the data with sensitive keys redacted 

69 """ 

70 if isinstance(data, dict): 

71 result = {} 

72 for key, value in data.items(): 

73 key_lower = key.lower() if isinstance(key, str) else key 

74 if key_lower in SENSITIVE_KEYS: 

75 result[key] = "[REDACTED]" 

76 else: 

77 result[key] = _sanitize_sensitive_data(value) 

78 return result 

79 if isinstance(data, list): 

80 return [_sanitize_sensitive_data(item) for item in data] 

81 return data 

82 

83 

84class FileWriteSecurityError(Exception): 

85 """Raised when a file write operation is not allowed by security settings.""" 

86 

87 pass 

88 

89 

90def write_file_verified( 

91 filepath: str | Path, 

92 content: str, 

93 setting_name: str, 

94 required_value: Any = True, 

95 context: str = "", 

96 mode: str = "w", 

97 encoding: str = "utf-8", 

98 settings_snapshot: dict = None, 

99 permissions: int | None = None, 

100) -> None: 

101 """Write content to a file only if security settings allow it. 

102 

103 Args: 

104 filepath: Path to the file to write 

105 content: Content to write to the file 

106 setting_name: Configuration setting name to check (e.g., "api.allow_file_output") 

107 required_value: Required value for the setting (default: True) 

108 context: Description of what's being written (for error messages) 

109 mode: File open mode (default: "w") 

110 encoding: File encoding (default: "utf-8") 

111 settings_snapshot: Optional settings snapshot for programmatic mode 

112 permissions: POSIX file mode for private output. Existing files are 

113 tightened before their contents are replaced. 

114 

115 Raises: 

116 FileWriteSecurityError: If the security setting doesn't match required value 

117 OSError: errno ELOOP, if the destination leaf is a symlink and the 

118 platform exposes O_NOFOLLOW 

119 

120 Example: 

121 >>> write_file_verified( 

122 ... "report.md", 

123 ... markdown_content, 

124 ... "api.allow_file_output", 

125 ... context="API research report" 

126 ... ) 

127 """ 

128 from ..config.search_config import get_setting_from_snapshot 

129 

130 try: 

131 actual_value = get_setting_from_snapshot( 

132 setting_name, settings_snapshot=settings_snapshot 

133 ) 

134 except Exception: 

135 # Setting doesn't exist - default deny 

136 actual_value = None 

137 

138 if actual_value != required_value: 

139 error_msg = ( 

140 f"File write not allowed: {context or 'file operation'}. " 

141 f"Set '{setting_name}={required_value}' in config to enable this feature." 

142 ) 

143 logger.warning(error_msg) 

144 raise FileWriteSecurityError(error_msg) 

145 

146 # Where O_NOFOLLOW is available, refuse a symlink at the final path 

147 # component. Parent-directory symlinks and a leaf already dereferenced 

148 # by the caller's Path.resolve() are outside this check. 

149 # Let open() validate modes, own the descriptor and handle text/binary 

150 # translation. This remains an in-place write, not an atomic replacement. 

151 opener = _open_nofollow 

152 if permissions is not None: 

153 if os.name != "posix" or not 0 <= permissions <= 0o777: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 raise ValueError("Private POSIX file permissions are unavailable") 

155 

156 def private_opener(path: str, flags: int) -> int: 

157 return _open_nofollow_with_permissions(path, flags, permissions) 

158 

159 opener = private_opener 

160 

161 if "b" in mode: 

162 with open(filepath, mode, opener=opener) as f: # nosec B603 

163 f.write(content) 

164 else: 

165 with open(filepath, mode, encoding=encoding, opener=opener) as f: # nosec B603 

166 f.write(content) 

167 

168 logger.debug( 

169 f"Verified file write: {filepath} (setting: {setting_name}={required_value})" 

170 ) 

171 

172 

173def write_json_verified( 

174 filepath: str | Path, 

175 data: dict | list, 

176 setting_name: str, 

177 required_value: Any = True, 

178 context: str = "", 

179 settings_snapshot: dict = None, 

180 permissions: int | None = None, 

181 **json_kwargs, 

182) -> None: 

183 """Write JSON data to a file only if security settings allow it. 

184 

185 Args: 

186 filepath: Path to the file to write 

187 data: Dictionary or list to serialize as JSON 

188 setting_name: Configuration setting name to check 

189 required_value: Required value for the setting (default: True) 

190 context: Description of what's being written (for error messages) 

191 settings_snapshot: Optional settings snapshot for programmatic mode 

192 permissions: POSIX file mode for private output. 

193 **json_kwargs: Additional keyword arguments to pass to json.dumps() 

194 (e.g., indent=2, ensure_ascii=False, sort_keys=True, default=custom_serializer) 

195 

196 Raises: 

197 FileWriteSecurityError: If the security setting doesn't match required value 

198 

199 Example: 

200 >>> write_json_verified( 

201 ... "results.json", 

202 ... {"accuracy": 0.95}, 

203 ... "benchmark.allow_file_output", 

204 ... context="benchmark results", 

205 ... indent=2, 

206 ... sort_keys=True 

207 ... ) 

208 """ 

209 # Default to indent=2 if not specified for readability 

210 if "indent" not in json_kwargs: 

211 json_kwargs["indent"] = 2 

212 

213 # Sanitize sensitive data before writing to disk 

214 sanitized_data = _sanitize_sensitive_data(data) 

215 content = json.dumps(sanitized_data, **json_kwargs) 

216 write_file_verified( 

217 filepath, 

218 content, 

219 setting_name, 

220 required_value, 

221 context, 

222 mode="w", 

223 encoding="utf-8", 

224 settings_snapshot=settings_snapshot, 

225 permissions=permissions, 

226 )