ostruct-cli 0.8.29__py3-none-any.whl → 1.0.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.
- ostruct/cli/__init__.py +3 -15
- ostruct/cli/attachment_processor.py +455 -0
- ostruct/cli/attachment_template_bridge.py +973 -0
- ostruct/cli/cli.py +157 -33
- ostruct/cli/click_options.py +775 -692
- ostruct/cli/code_interpreter.py +195 -12
- ostruct/cli/commands/__init__.py +0 -3
- ostruct/cli/commands/run.py +289 -62
- ostruct/cli/config.py +23 -22
- ostruct/cli/constants.py +89 -0
- ostruct/cli/errors.py +175 -5
- ostruct/cli/explicit_file_processor.py +0 -15
- ostruct/cli/file_info.py +97 -15
- ostruct/cli/file_list.py +43 -1
- ostruct/cli/file_search.py +68 -2
- ostruct/cli/help_json.py +235 -0
- ostruct/cli/mcp_integration.py +13 -16
- ostruct/cli/params.py +217 -0
- ostruct/cli/plan_assembly.py +335 -0
- ostruct/cli/plan_printing.py +385 -0
- ostruct/cli/progress_reporting.py +8 -56
- ostruct/cli/quick_ref_help.py +128 -0
- ostruct/cli/rich_config.py +299 -0
- ostruct/cli/runner.py +397 -190
- ostruct/cli/security/__init__.py +2 -0
- ostruct/cli/security/allowed_checker.py +41 -0
- ostruct/cli/security/normalization.py +13 -9
- ostruct/cli/security/security_manager.py +558 -17
- ostruct/cli/security/types.py +15 -0
- ostruct/cli/template_debug.py +283 -261
- ostruct/cli/template_debug_help.py +233 -142
- ostruct/cli/template_env.py +46 -5
- ostruct/cli/template_filters.py +415 -8
- ostruct/cli/template_processor.py +240 -619
- ostruct/cli/template_rendering.py +49 -73
- ostruct/cli/template_validation.py +2 -1
- ostruct/cli/token_validation.py +35 -15
- ostruct/cli/types.py +15 -19
- ostruct/cli/unicode_compat.py +283 -0
- ostruct/cli/upload_manager.py +448 -0
- ostruct/cli/validators.py +255 -54
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +230 -127
- ostruct_cli-1.0.0.dist-info/RECORD +80 -0
- ostruct/cli/commands/quick_ref.py +0 -54
- ostruct/cli/template_optimizer.py +0 -478
- ostruct_cli-0.8.29.dist-info/RECORD +0 -71
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
- {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
@@ -10,10 +10,11 @@ This module provides a high-level SecurityManager class that uses the other modu
|
|
10
10
|
|
11
11
|
import logging
|
12
12
|
import os
|
13
|
+
import stat
|
13
14
|
import tempfile
|
14
15
|
from contextlib import contextmanager
|
15
16
|
from pathlib import Path
|
16
|
-
from typing import Generator, List, Optional, Union
|
17
|
+
from typing import Generator, List, Optional, Tuple, Union
|
17
18
|
|
18
19
|
from ostruct.cli.errors import OstructFileNotFoundError
|
19
20
|
|
@@ -26,6 +27,7 @@ from .errors import (
|
|
26
27
|
)
|
27
28
|
from .normalization import normalize_path
|
28
29
|
from .symlink_resolver import _resolve_symlink
|
30
|
+
from .types import PathSecurity
|
29
31
|
|
30
32
|
logger = logging.getLogger(__name__)
|
31
33
|
|
@@ -64,6 +66,7 @@ class SecurityManager:
|
|
64
66
|
allowed_dirs: Optional[List[Union[str, Path]]] = None,
|
65
67
|
allow_temp_paths: bool = False,
|
66
68
|
max_symlink_depth: int = MAX_SYMLINK_DEPTH,
|
69
|
+
security_mode: PathSecurity = PathSecurity.WARN,
|
67
70
|
):
|
68
71
|
"""Initialize the SecurityManager.
|
69
72
|
|
@@ -74,6 +77,7 @@ class SecurityManager:
|
|
74
77
|
allowed_dirs: Additional directories allowed for access.
|
75
78
|
allow_temp_paths: Whether to allow temporary directory paths.
|
76
79
|
max_symlink_depth: Maximum depth for symlink resolution.
|
80
|
+
security_mode: Path security enforcement mode (PERMISSIVE, WARN, or STRICT).
|
77
81
|
|
78
82
|
Raises:
|
79
83
|
DirectoryNotFoundError: If base_dir or any allowed directory doesn't exist.
|
@@ -98,18 +102,26 @@ class SecurityManager:
|
|
98
102
|
normalize_path(tempfile.gettempdir()) if allow_temp_paths else None
|
99
103
|
)
|
100
104
|
|
105
|
+
# Enhanced security features (T2.1)
|
106
|
+
self.security_mode = security_mode
|
107
|
+
self._allow_inodes: set[tuple[int, int]] = (
|
108
|
+
set()
|
109
|
+
) # (device, inode) pairs
|
110
|
+
|
101
111
|
logger.debug(
|
102
112
|
"\n=== Initialized SecurityManager ===\n"
|
103
113
|
"Base dir: %s\n"
|
104
114
|
"Allowed dirs: %s\n"
|
105
115
|
"Allow temp: %s\n"
|
106
116
|
"Temp dir: %s\n"
|
107
|
-
"Max symlink depth: %d"
|
117
|
+
"Max symlink depth: %d\n"
|
118
|
+
"Security mode: %s",
|
108
119
|
self._base_dir,
|
109
120
|
self._allowed_dirs,
|
110
121
|
self._allow_temp_paths,
|
111
122
|
self._temp_dir,
|
112
123
|
self._max_symlink_depth,
|
124
|
+
self.security_mode,
|
113
125
|
)
|
114
126
|
|
115
127
|
@property
|
@@ -140,6 +152,496 @@ class SecurityManager:
|
|
140
152
|
if norm_dir not in self._allowed_dirs:
|
141
153
|
self._allowed_dirs.append(norm_dir)
|
142
154
|
|
155
|
+
def configure_security_mode(
|
156
|
+
self,
|
157
|
+
mode: PathSecurity,
|
158
|
+
allow_files: Optional[List[str]] = None,
|
159
|
+
allow_lists: Optional[List[str]] = None,
|
160
|
+
) -> None:
|
161
|
+
"""Configure enhanced security features while preserving existing functionality.
|
162
|
+
|
163
|
+
Args:
|
164
|
+
mode: Path security enforcement mode
|
165
|
+
allow_files: List of individual file paths to allow (tracked by inode)
|
166
|
+
allow_lists: List of allow-list files to load
|
167
|
+
|
168
|
+
Raises:
|
169
|
+
DirectoryNotFoundError: If any allow-list file doesn't exist
|
170
|
+
PathSecurityError: If there's an error processing allow lists
|
171
|
+
"""
|
172
|
+
self.security_mode = mode
|
173
|
+
logger.debug("Configuring security mode: %s", mode)
|
174
|
+
|
175
|
+
# Process individual files by inode (new feature)
|
176
|
+
if allow_files:
|
177
|
+
for file_path in allow_files:
|
178
|
+
if not self.pin_file_by_inode(file_path):
|
179
|
+
logger.warning(
|
180
|
+
"Could not add file to allowlist: %s", file_path
|
181
|
+
)
|
182
|
+
|
183
|
+
# Process allow lists (new feature)
|
184
|
+
if allow_lists:
|
185
|
+
for list_path in allow_lists:
|
186
|
+
self._load_allow_list(list_path)
|
187
|
+
|
188
|
+
def _load_allow_list(self, list_path: str) -> None:
|
189
|
+
"""Load allowed paths from a file.
|
190
|
+
|
191
|
+
Args:
|
192
|
+
list_path: Path to the allow-list file
|
193
|
+
|
194
|
+
Raises:
|
195
|
+
DirectoryNotFoundError: If the allow-list file doesn't exist
|
196
|
+
PathSecurityError: If there's an error processing the file
|
197
|
+
"""
|
198
|
+
try:
|
199
|
+
list_file = normalize_path(list_path)
|
200
|
+
if not list_file.exists():
|
201
|
+
raise DirectoryNotFoundError(
|
202
|
+
f"Allow-list file not found: {list_path}",
|
203
|
+
path=list_path,
|
204
|
+
)
|
205
|
+
|
206
|
+
with open(list_file, "r", encoding="utf-8") as f:
|
207
|
+
for line_num, line in enumerate(f, 1):
|
208
|
+
line = line.strip()
|
209
|
+
if not line or line.startswith("#"):
|
210
|
+
continue # Skip empty lines and comments
|
211
|
+
|
212
|
+
try:
|
213
|
+
# Try to interpret as directory first
|
214
|
+
path_obj = normalize_path(line)
|
215
|
+
if path_obj.is_dir():
|
216
|
+
self.add_allowed_directory(path_obj)
|
217
|
+
logger.debug(
|
218
|
+
"Added directory from allow-list: %s", line
|
219
|
+
)
|
220
|
+
elif path_obj.is_file():
|
221
|
+
# Add as inode-tracked file using secure pinning
|
222
|
+
if self.pin_file_by_inode(path_obj):
|
223
|
+
logger.debug(
|
224
|
+
"Added file from allow-list: %s", line
|
225
|
+
)
|
226
|
+
else:
|
227
|
+
logger.warning(
|
228
|
+
"Failed to pin file from allow-list: %s",
|
229
|
+
line,
|
230
|
+
)
|
231
|
+
else:
|
232
|
+
logger.warning(
|
233
|
+
"Path in allow-list does not exist: %s (line %d)",
|
234
|
+
line,
|
235
|
+
line_num,
|
236
|
+
)
|
237
|
+
except Exception as e:
|
238
|
+
logger.warning(
|
239
|
+
"Error processing allow-list entry '%s' (line %d): %s",
|
240
|
+
line,
|
241
|
+
line_num,
|
242
|
+
e,
|
243
|
+
)
|
244
|
+
|
245
|
+
except OSError as e:
|
246
|
+
raise PathSecurityError(
|
247
|
+
f"Failed to read allow-list file: {e}",
|
248
|
+
path=list_path,
|
249
|
+
context={"reason": "allow_list_read_error"},
|
250
|
+
) from e
|
251
|
+
|
252
|
+
def pin_file_by_inode(self, file_path: Union[str, Path]) -> bool:
|
253
|
+
"""Pin a file to allowlist by its inode (survives moves/renames).
|
254
|
+
|
255
|
+
Uses O_NOFOLLOW for security on Unix systems to prevent symlink attacks.
|
256
|
+
Falls back to Windows-compatible approach when O_NOFOLLOW is unavailable.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
file_path: Path to the file to pin
|
260
|
+
|
261
|
+
Returns:
|
262
|
+
True if file was successfully pinned, False otherwise
|
263
|
+
"""
|
264
|
+
try:
|
265
|
+
path = normalize_path(file_path)
|
266
|
+
|
267
|
+
# Use O_NOFOLLOW to prevent symlink attacks during stat
|
268
|
+
# On Windows, O_NOFOLLOW is not supported, use alternative approach
|
269
|
+
if hasattr(os, "O_NOFOLLOW") and os.name != "nt":
|
270
|
+
# Unix-like systems: use O_NOFOLLOW for security
|
271
|
+
try:
|
272
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
273
|
+
try:
|
274
|
+
st = os.fstat(fd)
|
275
|
+
inode_id = self._get_file_identity(st)
|
276
|
+
self._allow_inodes.add(inode_id)
|
277
|
+
logger.debug(
|
278
|
+
"Pinned file %s by inode %s (O_NOFOLLOW)",
|
279
|
+
path,
|
280
|
+
inode_id,
|
281
|
+
)
|
282
|
+
return True
|
283
|
+
finally:
|
284
|
+
os.close(fd)
|
285
|
+
except OSError as e:
|
286
|
+
# Handle pyfakefs compatibility issues
|
287
|
+
if (
|
288
|
+
e.errno == 9
|
289
|
+
): # EBADF - Bad file descriptor (common in fake filesystem)
|
290
|
+
logger.debug(
|
291
|
+
"Fake filesystem detected, falling back to lstat: %s",
|
292
|
+
path,
|
293
|
+
)
|
294
|
+
# Fall through to Windows-style approach
|
295
|
+
elif e.errno == 40: # ELOOP - symlink loop
|
296
|
+
logger.warning("Symlink loop detected: %s", path)
|
297
|
+
return False
|
298
|
+
elif e.errno == 20: # ENOTDIR - symlink in path
|
299
|
+
logger.warning("Symlink in path: %s", path)
|
300
|
+
return False
|
301
|
+
else:
|
302
|
+
# For other errors, fall back to Windows approach rather than failing
|
303
|
+
logger.debug(
|
304
|
+
"O_NOFOLLOW failed with error %s, falling back to lstat: %s",
|
305
|
+
e.errno,
|
306
|
+
path,
|
307
|
+
)
|
308
|
+
|
309
|
+
# Windows fallback (also used when O_NOFOLLOW fails in fake filesystem)
|
310
|
+
# Windows fallback: check for symlinks manually
|
311
|
+
st_before = os.lstat(path)
|
312
|
+
if stat.S_ISLNK(st_before.st_mode):
|
313
|
+
logger.warning(
|
314
|
+
"Symlink detected, using resolved target: %s", path
|
315
|
+
)
|
316
|
+
# For symlinks, stat the resolved target
|
317
|
+
resolved = path.resolve()
|
318
|
+
st_after = os.stat(resolved)
|
319
|
+
inode_id = self._get_file_identity(st_after)
|
320
|
+
else:
|
321
|
+
# Regular file
|
322
|
+
inode_id = self._get_file_identity(st_before)
|
323
|
+
|
324
|
+
self._allow_inodes.add(inode_id)
|
325
|
+
logger.debug(
|
326
|
+
"Pinned file %s by inode %s (Windows)", path, inode_id
|
327
|
+
)
|
328
|
+
return True
|
329
|
+
|
330
|
+
except OSError as e:
|
331
|
+
logger.error("Cannot pin file %s: %s", file_path, e)
|
332
|
+
return False
|
333
|
+
|
334
|
+
def _get_file_identity(
|
335
|
+
self, stat_result: os.stat_result
|
336
|
+
) -> Tuple[int, int]:
|
337
|
+
"""Get platform-appropriate file identity.
|
338
|
+
|
339
|
+
Args:
|
340
|
+
stat_result: Result from os.stat() or os.fstat()
|
341
|
+
|
342
|
+
Returns:
|
343
|
+
Tuple of (device, inode) for file identity
|
344
|
+
"""
|
345
|
+
# Windows: Python's stat_result may not have reliable st_ino
|
346
|
+
# Use combination of device + file index when available
|
347
|
+
if os.name == "nt":
|
348
|
+
# Windows fallback: use dev + (file_index or ino) + size + ctime
|
349
|
+
# This provides reasonable identity even with NTFS limitations
|
350
|
+
file_id = getattr(stat_result, "st_file_index", stat_result.st_ino)
|
351
|
+
# Include size and ctime for additional uniqueness on Windows
|
352
|
+
extended_id = hash(
|
353
|
+
(file_id, stat_result.st_size, stat_result.st_ctime_ns)
|
354
|
+
)
|
355
|
+
return (stat_result.st_dev, extended_id)
|
356
|
+
else:
|
357
|
+
# Unix-like: Use device and inode (standard approach)
|
358
|
+
return (stat_result.st_dev, stat_result.st_ino)
|
359
|
+
|
360
|
+
def is_file_allowed_by_inode(self, file_path: Union[str, Path]) -> bool:
|
361
|
+
"""Check if file is allowed based on its inode.
|
362
|
+
|
363
|
+
For explicitly allowed files, allow path traversal to enable access
|
364
|
+
via alternative paths that resolve to the same file.
|
365
|
+
|
366
|
+
Args:
|
367
|
+
file_path: Path to check
|
368
|
+
|
369
|
+
Returns:
|
370
|
+
True if file is in the inode allowlist
|
371
|
+
"""
|
372
|
+
try:
|
373
|
+
# First try normal normalization (blocks path traversal)
|
374
|
+
try:
|
375
|
+
path = normalize_path(file_path)
|
376
|
+
except PathSecurityError as e:
|
377
|
+
# If path traversal was blocked, try with traversal allowed
|
378
|
+
# This enables access to explicitly allowed files via alternative paths
|
379
|
+
if "Directory traversal not allowed" in str(e):
|
380
|
+
path = normalize_path(file_path, allow_traversal=True)
|
381
|
+
else:
|
382
|
+
# Other security errors (unsafe Unicode, etc.) are not bypassed
|
383
|
+
return False
|
384
|
+
|
385
|
+
st = os.stat(path, follow_symlinks=False)
|
386
|
+
file_id = self._get_file_identity(st)
|
387
|
+
is_allowed = file_id in self._allow_inodes
|
388
|
+
|
389
|
+
if is_allowed:
|
390
|
+
logger.debug(
|
391
|
+
"File allowed by inode: %s -> %s", file_path, file_id
|
392
|
+
)
|
393
|
+
|
394
|
+
return is_allowed
|
395
|
+
except OSError:
|
396
|
+
return False
|
397
|
+
|
398
|
+
def validate_symlink_target(self, symlink_path: Path) -> bool:
|
399
|
+
"""Validate that symlink target is also allowed.
|
400
|
+
|
401
|
+
Args:
|
402
|
+
symlink_path: Path to the symlink to validate
|
403
|
+
|
404
|
+
Returns:
|
405
|
+
True if symlink target is allowed
|
406
|
+
|
407
|
+
Raises:
|
408
|
+
PathSecurityError: If target is not allowed and mode is STRICT
|
409
|
+
"""
|
410
|
+
if not symlink_path.is_symlink():
|
411
|
+
return True # Not a symlink
|
412
|
+
|
413
|
+
try:
|
414
|
+
target = symlink_path.resolve()
|
415
|
+
|
416
|
+
# Check if target is allowed by existing rules
|
417
|
+
if self.is_path_allowed_enhanced(target):
|
418
|
+
return True
|
419
|
+
|
420
|
+
# Handle security mode for disallowed targets
|
421
|
+
if self.security_mode == PathSecurity.STRICT:
|
422
|
+
raise PathSecurityError(
|
423
|
+
f"Symlink target not allowed: {target}",
|
424
|
+
path=str(symlink_path),
|
425
|
+
context={
|
426
|
+
"reason": "symlink_target_denied",
|
427
|
+
"target": str(target),
|
428
|
+
"security_mode": self.security_mode.value,
|
429
|
+
},
|
430
|
+
)
|
431
|
+
elif self.security_mode == PathSecurity.WARN:
|
432
|
+
logger.warning(
|
433
|
+
"Symlink target outside policy: %s -> %s",
|
434
|
+
symlink_path,
|
435
|
+
target,
|
436
|
+
)
|
437
|
+
return True
|
438
|
+
else: # PERMISSIVE
|
439
|
+
return True
|
440
|
+
|
441
|
+
except OSError as e:
|
442
|
+
# Broken symlink or other error
|
443
|
+
if self.security_mode == PathSecurity.STRICT:
|
444
|
+
raise PathSecurityError(
|
445
|
+
f"Cannot validate symlink target: {e}",
|
446
|
+
path=str(symlink_path),
|
447
|
+
context={
|
448
|
+
"reason": "symlink_validation_error",
|
449
|
+
"error": str(e),
|
450
|
+
},
|
451
|
+
) from e
|
452
|
+
else:
|
453
|
+
logger.warning(
|
454
|
+
"Cannot validate symlink target %s: %s", symlink_path, e
|
455
|
+
)
|
456
|
+
return self.security_mode != PathSecurity.STRICT
|
457
|
+
|
458
|
+
def validate_file_access(
|
459
|
+
self, file_path: Union[str, Path], context: str = "file access"
|
460
|
+
) -> Path:
|
461
|
+
"""Main entry point for file access validation.
|
462
|
+
|
463
|
+
This method provides a unified interface for validating file access that:
|
464
|
+
1. Checks file existence
|
465
|
+
2. Applies enhanced security validation (directory + inode + mode)
|
466
|
+
3. Validates symlink targets when appropriate
|
467
|
+
4. Provides clear error messages with context
|
468
|
+
|
469
|
+
Args:
|
470
|
+
file_path: Path to the file to validate
|
471
|
+
context: Description of the access context for error messages
|
472
|
+
|
473
|
+
Returns:
|
474
|
+
Validated and resolved Path object
|
475
|
+
|
476
|
+
Raises:
|
477
|
+
OstructFileNotFoundError: If file doesn't exist
|
478
|
+
PathSecurityError: If file access is denied by security policy
|
479
|
+
"""
|
480
|
+
logger.debug(
|
481
|
+
"Validating file access: %s (context: %s)", file_path, context
|
482
|
+
)
|
483
|
+
|
484
|
+
try:
|
485
|
+
# Normalize and resolve the path
|
486
|
+
resolved_path = normalize_path(file_path).resolve()
|
487
|
+
except PathSecurityError as e:
|
488
|
+
# If path traversal was blocked, check if file is explicitly allowed
|
489
|
+
if "Directory traversal not allowed" in str(e):
|
490
|
+
# Check if this file is allowed by inode (which handles traversal)
|
491
|
+
if self.is_file_allowed_by_inode(file_path):
|
492
|
+
# Allow path traversal for explicitly allowed files
|
493
|
+
resolved_path = normalize_path(
|
494
|
+
file_path, allow_traversal=True
|
495
|
+
).resolve()
|
496
|
+
else:
|
497
|
+
raise PathSecurityError(
|
498
|
+
f"Failed to resolve path: {file_path}",
|
499
|
+
path=str(file_path),
|
500
|
+
context={
|
501
|
+
"reason": "path_resolution_error",
|
502
|
+
"error": str(e),
|
503
|
+
},
|
504
|
+
) from e
|
505
|
+
else:
|
506
|
+
raise PathSecurityError(
|
507
|
+
f"Failed to resolve path: {file_path}",
|
508
|
+
path=str(file_path),
|
509
|
+
context={
|
510
|
+
"reason": "path_resolution_error",
|
511
|
+
"error": str(e),
|
512
|
+
},
|
513
|
+
) from e
|
514
|
+
except Exception as e:
|
515
|
+
raise PathSecurityError(
|
516
|
+
f"Failed to resolve path: {file_path}",
|
517
|
+
path=str(file_path),
|
518
|
+
context={"reason": "path_resolution_error", "error": str(e)},
|
519
|
+
) from e
|
520
|
+
|
521
|
+
# Check if file exists
|
522
|
+
if not resolved_path.exists():
|
523
|
+
raise OstructFileNotFoundError(f"File not found: {file_path}")
|
524
|
+
|
525
|
+
# Apply enhanced security validation on the resolved path
|
526
|
+
# For symlinks, this ensures we validate the target, not just the symlink itself
|
527
|
+
if not self.is_path_allowed_enhanced(resolved_path):
|
528
|
+
# is_path_allowed_enhanced() handles mode-specific behavior (warn vs strict)
|
529
|
+
# If we get here in STRICT mode, it means an exception was already raised
|
530
|
+
# In WARN/PERMISSIVE modes, we continue with a warning already logged
|
531
|
+
pass
|
532
|
+
|
533
|
+
# Additional symlink validation for enhanced security
|
534
|
+
if resolved_path.is_symlink():
|
535
|
+
self.validate_symlink_target(resolved_path)
|
536
|
+
|
537
|
+
logger.debug(
|
538
|
+
"File access validated: %s (context: %s)", resolved_path, context
|
539
|
+
)
|
540
|
+
return resolved_path
|
541
|
+
|
542
|
+
def validate_batch_access(
|
543
|
+
self,
|
544
|
+
paths: List[Union[str, Path]],
|
545
|
+
context: str = "batch access",
|
546
|
+
) -> List[Path]:
|
547
|
+
"""Validate multiple paths efficiently.
|
548
|
+
|
549
|
+
Args:
|
550
|
+
paths: List of paths to validate
|
551
|
+
context: Description of the access context
|
552
|
+
|
553
|
+
Returns:
|
554
|
+
List of validated Path objects
|
555
|
+
|
556
|
+
Raises:
|
557
|
+
PathSecurityError: If any path fails validation in STRICT mode
|
558
|
+
"""
|
559
|
+
validated = []
|
560
|
+
errors = []
|
561
|
+
|
562
|
+
for path in paths:
|
563
|
+
try:
|
564
|
+
validated.append(self.validate_file_access(path, context))
|
565
|
+
except (OstructFileNotFoundError, PathSecurityError) as e:
|
566
|
+
error_msg = f"{path}: {e}"
|
567
|
+
errors.append(error_msg)
|
568
|
+
logger.debug("Batch validation error: %s", error_msg)
|
569
|
+
|
570
|
+
if errors:
|
571
|
+
if self.security_mode == PathSecurity.STRICT:
|
572
|
+
raise PathSecurityError(
|
573
|
+
"Batch validation failed:\n" + "\n".join(errors),
|
574
|
+
context={
|
575
|
+
"reason": "batch_validation_failed",
|
576
|
+
"errors": errors,
|
577
|
+
"context": context,
|
578
|
+
},
|
579
|
+
)
|
580
|
+
else:
|
581
|
+
# In WARN/PERMISSIVE modes, log errors but continue
|
582
|
+
for error in errors:
|
583
|
+
logger.warning("Batch validation: %s", error)
|
584
|
+
|
585
|
+
logger.debug(
|
586
|
+
"Batch validation completed: %d/%d files validated (context: %s)",
|
587
|
+
len(validated),
|
588
|
+
len(paths),
|
589
|
+
context,
|
590
|
+
)
|
591
|
+
return validated
|
592
|
+
|
593
|
+
@contextmanager
|
594
|
+
def security_context(
|
595
|
+
self,
|
596
|
+
mode: PathSecurity,
|
597
|
+
additional_allows: Optional[List[str]] = None,
|
598
|
+
) -> Generator[None, None, None]:
|
599
|
+
"""Temporary security context for specific operations.
|
600
|
+
|
601
|
+
Args:
|
602
|
+
mode: Temporary security mode to use
|
603
|
+
additional_allows: Additional directories to temporarily allow
|
604
|
+
|
605
|
+
Yields:
|
606
|
+
None (context manager)
|
607
|
+
|
608
|
+
Example:
|
609
|
+
with manager.security_context(PathSecurity.PERMISSIVE):
|
610
|
+
# Temporarily allow all file access
|
611
|
+
result = manager.validate_file_access(sensitive_file)
|
612
|
+
"""
|
613
|
+
# Save current state
|
614
|
+
old_mode = self.security_mode
|
615
|
+
old_dirs = self._allowed_dirs.copy()
|
616
|
+
old_inodes = self._allow_inodes.copy()
|
617
|
+
|
618
|
+
try:
|
619
|
+
# Apply temporary configuration
|
620
|
+
self.security_mode = mode
|
621
|
+
if additional_allows:
|
622
|
+
for path in additional_allows:
|
623
|
+
try:
|
624
|
+
self.add_allowed_directory(path)
|
625
|
+
logger.debug("Temporarily added directory: %s", path)
|
626
|
+
except Exception as e:
|
627
|
+
logger.warning(
|
628
|
+
"Failed to add temporary directory %s: %s", path, e
|
629
|
+
)
|
630
|
+
|
631
|
+
logger.debug(
|
632
|
+
"Entered security context: mode=%s, additional_dirs=%s",
|
633
|
+
mode,
|
634
|
+
additional_allows,
|
635
|
+
)
|
636
|
+
yield
|
637
|
+
|
638
|
+
finally:
|
639
|
+
# Restore original state
|
640
|
+
self.security_mode = old_mode
|
641
|
+
self._allowed_dirs = old_dirs
|
642
|
+
self._allow_inodes = old_inodes
|
643
|
+
logger.debug("Restored security context: mode=%s", old_mode)
|
644
|
+
|
143
645
|
def is_temp_path(self, path: Union[str, Path]) -> bool:
|
144
646
|
"""Check if a path is in the system's temporary directory.
|
145
647
|
|
@@ -191,6 +693,50 @@ class SecurityManager:
|
|
191
693
|
|
192
694
|
return False
|
193
695
|
|
696
|
+
def is_path_allowed_enhanced(self, path: Union[str, Path]) -> bool:
|
697
|
+
"""Enhanced path validation with three-tier security model.
|
698
|
+
|
699
|
+
Precedence Rules (Reviewer feedback addressed):
|
700
|
+
1. Existing SecurityManager validation (directory allowlist) - highest precedence
|
701
|
+
2. Inode allowlist (--allow-file) - file-specific tracking
|
702
|
+
3. Security mode (permissive/warn/strict) - fallback behavior
|
703
|
+
|
704
|
+
Args:
|
705
|
+
path: The path to check
|
706
|
+
|
707
|
+
Returns:
|
708
|
+
True if the path is allowed; False otherwise
|
709
|
+
|
710
|
+
Raises:
|
711
|
+
PathSecurityError: If security mode is STRICT and path is not allowed
|
712
|
+
"""
|
713
|
+
# Rule 1: Check inode allowlist first (handles path traversal to allowed files)
|
714
|
+
if self.is_file_allowed_by_inode(path):
|
715
|
+
return True
|
716
|
+
|
717
|
+
# Rule 2: Use existing SecurityManager validation (preserves current behavior)
|
718
|
+
# This requires successful path normalization
|
719
|
+
try:
|
720
|
+
resolved_path = normalize_path(path).resolve()
|
721
|
+
if self.is_path_allowed(path):
|
722
|
+
return True
|
723
|
+
except (PathSecurityError, OSError):
|
724
|
+
# If we can't normalize the path and it's not in inode allowlist,
|
725
|
+
# defer to security mode
|
726
|
+
resolved_path = None
|
727
|
+
|
728
|
+
# Rule 3: Apply security mode (NEW - three-tier model)
|
729
|
+
if self.security_mode == PathSecurity.PERMISSIVE:
|
730
|
+
logger.debug("Path allowed by permissive mode: %s", path)
|
731
|
+
return True
|
732
|
+
elif self.security_mode == PathSecurity.WARN:
|
733
|
+
logger.warning("PathOutsidePolicy: %s", resolved_path or path)
|
734
|
+
return True
|
735
|
+
else: # STRICT
|
736
|
+
raise PathSecurityError(
|
737
|
+
f"Path not in allowlist: {resolved_path or path}"
|
738
|
+
)
|
739
|
+
|
194
740
|
def validate_path(self, path: Union[str, Path]) -> Path:
|
195
741
|
"""Validate a path against security rules.
|
196
742
|
|
@@ -226,6 +772,10 @@ class SecurityManager:
|
|
226
772
|
self._allowed_dirs,
|
227
773
|
)
|
228
774
|
logger.debug("Resolved symlink to: %s", resolved)
|
775
|
+
|
776
|
+
# Additional symlink target validation for enhanced security
|
777
|
+
self.validate_symlink_target(norm_path)
|
778
|
+
|
229
779
|
return resolved
|
230
780
|
except RuntimeError as e:
|
231
781
|
if "Symlink loop" in str(e):
|
@@ -354,21 +904,12 @@ class SecurityManager:
|
|
354
904
|
# Any other security errors propagate unchanged
|
355
905
|
raise
|
356
906
|
|
357
|
-
# For non-symlinks, check if the normalized path is allowed
|
358
|
-
if not self.
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
raise PathSecurityError(
|
364
|
-
f"Access denied: {os.path.basename(str(path))} is outside base directory",
|
365
|
-
path=str(path),
|
366
|
-
context={
|
367
|
-
"reason": SecurityErrorReasons.PATH_OUTSIDE_ALLOWED,
|
368
|
-
"base_dir": str(self._base_dir),
|
369
|
-
"allowed_dirs": [str(d) for d in self._allowed_dirs],
|
370
|
-
},
|
371
|
-
)
|
907
|
+
# For non-symlinks, check if the normalized path is allowed using enhanced security
|
908
|
+
if not self.is_path_allowed_enhanced(norm_path):
|
909
|
+
# is_path_allowed_enhanced() handles mode-specific behavior (warn vs strict)
|
910
|
+
# If we get here in STRICT mode, it means an exception was already raised
|
911
|
+
# In WARN/PERMISSIVE modes, we continue with a warning already logged
|
912
|
+
pass
|
372
913
|
|
373
914
|
# Only check existence after security validation
|
374
915
|
if not norm_path.exists():
|
ostruct/cli/security/types.py
CHANGED
@@ -1,10 +1,25 @@
|
|
1
1
|
"""Security type definitions and protocols."""
|
2
2
|
|
3
|
+
import enum
|
3
4
|
from contextlib import AbstractContextManager
|
4
5
|
from pathlib import Path
|
5
6
|
from typing import List, Protocol, Union
|
6
7
|
|
7
8
|
|
9
|
+
class PathSecurity(enum.Enum):
|
10
|
+
"""Path security enforcement modes for file access control.
|
11
|
+
|
12
|
+
This enum defines three levels of path security enforcement:
|
13
|
+
- PERMISSIVE: Allow all paths without restriction
|
14
|
+
- WARN: Allow all paths but log warnings for potentially unsafe access
|
15
|
+
- STRICT: Only allow explicitly permitted paths (via directory or inode allowlists)
|
16
|
+
"""
|
17
|
+
|
18
|
+
PERMISSIVE = "permissive" # Allow all paths
|
19
|
+
WARN = "warn" # Allow with warnings
|
20
|
+
STRICT = "strict" # Only allow explicitly permitted paths
|
21
|
+
|
22
|
+
|
8
23
|
class SecurityManagerProtocol(Protocol):
|
9
24
|
"""Protocol defining the interface for security management."""
|
10
25
|
|