ostruct-cli 0.8.29__py3-none-any.whl → 1.0.1__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.
Files changed (49) hide show
  1. ostruct/cli/__init__.py +3 -15
  2. ostruct/cli/attachment_processor.py +455 -0
  3. ostruct/cli/attachment_template_bridge.py +973 -0
  4. ostruct/cli/cli.py +157 -33
  5. ostruct/cli/click_options.py +775 -692
  6. ostruct/cli/code_interpreter.py +195 -12
  7. ostruct/cli/commands/__init__.py +0 -3
  8. ostruct/cli/commands/run.py +289 -62
  9. ostruct/cli/config.py +23 -22
  10. ostruct/cli/constants.py +89 -0
  11. ostruct/cli/errors.py +175 -5
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +97 -15
  14. ostruct/cli/file_list.py +43 -1
  15. ostruct/cli/file_search.py +68 -2
  16. ostruct/cli/help_json.py +235 -0
  17. ostruct/cli/mcp_integration.py +13 -16
  18. ostruct/cli/params.py +217 -0
  19. ostruct/cli/plan_assembly.py +335 -0
  20. ostruct/cli/plan_printing.py +385 -0
  21. ostruct/cli/progress_reporting.py +8 -56
  22. ostruct/cli/quick_ref_help.py +128 -0
  23. ostruct/cli/rich_config.py +299 -0
  24. ostruct/cli/runner.py +397 -190
  25. ostruct/cli/security/__init__.py +2 -0
  26. ostruct/cli/security/allowed_checker.py +41 -0
  27. ostruct/cli/security/normalization.py +13 -9
  28. ostruct/cli/security/security_manager.py +558 -17
  29. ostruct/cli/security/types.py +15 -0
  30. ostruct/cli/template_debug.py +283 -261
  31. ostruct/cli/template_debug_help.py +233 -142
  32. ostruct/cli/template_env.py +46 -5
  33. ostruct/cli/template_filters.py +415 -8
  34. ostruct/cli/template_processor.py +240 -619
  35. ostruct/cli/template_rendering.py +49 -73
  36. ostruct/cli/template_validation.py +2 -1
  37. ostruct/cli/token_validation.py +35 -15
  38. ostruct/cli/types.py +15 -19
  39. ostruct/cli/unicode_compat.py +283 -0
  40. ostruct/cli/upload_manager.py +448 -0
  41. ostruct/cli/validators.py +255 -54
  42. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/METADATA +231 -128
  43. ostruct_cli-1.0.1.dist-info/RECORD +80 -0
  44. ostruct/cli/commands/quick_ref.py +0 -54
  45. ostruct/cli/template_optimizer.py +0 -478
  46. ostruct_cli-0.8.29.dist-info/RECORD +0 -71
  47. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/LICENSE +0 -0
  48. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/WHEEL +0 -0
  49. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.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.is_path_allowed(norm_path):
359
- logger.error(
360
- "Security violation: Path %s is outside allowed directories",
361
- path,
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():
@@ -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