@@ -427,3 +427,109 @@ def _first_non_option(args: list[str]) -> str | None:
427427 if not arg .startswith ("-" ):
428428 return arg
429429 return None
430+
431+
432+ # --- Destructive (irreversible) classification -----------------------------
433+ # Distinct from "mutating": `mkdir`/`touch` mutate the workspace but are easy to
434+ # undo, so they only matter for read-only profile enforcement. A *destructive*
435+ # command is hard/impossible to reverse (recursive force-delete, force-push,
436+ # hard reset, raw disk writes), so in auto mode it routes the agent into a
437+ # deliberation turn instead of being auto-approved. The two questions are
438+ # deliberately separate; this reuses the same token parser as
439+ # ``shell_mutation_reason`` so it inherits the wrapper/quote/chain hardening.
440+ _OPAQUE_INTERPRETERS = {
441+ "bash" ,
442+ "sh" ,
443+ "zsh" ,
444+ "dash" ,
445+ "ksh" ,
446+ "csh" ,
447+ "tcsh" ,
448+ "fish" ,
449+ "lua" ,
450+ "node" ,
451+ "perl" ,
452+ "python" ,
453+ "python3" ,
454+ "ruby" ,
455+ }
456+ # Flags that hand an interpreter inline code the token parser cannot inspect.
457+ # A bare `python script.py` is NOT opaque; only inline `-c`/`-e` code is.
458+ _INLINE_CODE_FLAGS = {"-c" , "-e" }
459+
460+
461+ def _short_flag_letters (arg : str ) -> set [str ]:
462+ """Letters of a clustered short-flag arg: ``-rf`` -> ``{'r', 'f'}``.
463+
464+ Long flags (``--force``) and non-flag tokens return an empty set.
465+ """
466+ if len (arg ) < 2 or not arg .startswith ("-" ) or arg .startswith ("--" ):
467+ return set ()
468+ letters = arg [1 :]
469+ if not letters .isalpha ():
470+ return set ()
471+ return set (letters )
472+
473+
474+ def shell_destructive_reason (command : str ) -> str | None :
475+ """Best-effort guard for *irreversible* shell commands warranting deliberation.
476+
477+ Returns a human-readable reason when the command is destructive, else ``None``.
478+ Shares the tokenization path of :func:`shell_mutation_reason` (``shlex`` split,
479+ wrapper unwrap, git-subcommand extraction), so ``sudo``/``env`` wrappers,
480+ quoting, and ``;``/``&&``/``||``/``|`` chains are all covered. Unparsable input
481+ is treated conservatively as destructive.
482+ """
483+ try :
484+ tokens = shlex .split (command , posix = True )
485+ except ValueError :
486+ return "unparsable shell command"
487+
488+ segment : list [str ] = []
489+ for token in [* tokens , ";" ]:
490+ if token in _SHELL_SEGMENT_SEPARATORS :
491+ reason = _segment_destructive_reason (segment )
492+ if reason is not None :
493+ return reason
494+ segment = []
495+ else :
496+ segment .append (token )
497+ return None
498+
499+
500+ def _segment_destructive_reason (tokens : list [str ]) -> str | None :
501+ if not tokens :
502+ return None
503+ command , args = _unwrap_command (tokens )
504+ if command is None :
505+ return None
506+ base = command .rsplit ("/" , 1 )[- 1 ]
507+
508+ if base == "rm" :
509+ recursive = any (
510+ arg in ("-r" , "-R" , "--recursive" ) or bool ({"r" , "R" } & _short_flag_letters (arg ))
511+ for arg in args
512+ )
513+ forced = any (arg == "--force" or "f" in _short_flag_letters (arg ) for arg in args )
514+ # Phase 1: require BOTH recursive and force. `rm -r dir` (no -f) and
515+ # `rm -f file` (no -r) are intentionally allowed to limit chattiness.
516+ return "rm recursive force delete" if recursive and forced else None
517+ if base in ("dd" , "truncate" ):
518+ return f"{ base } raw write"
519+ if base == "git" :
520+ subcommand = _git_subcommand (args )
521+ if subcommand == "push" and any (
522+ arg in ("--force" , "-f" ) or arg .startswith ("--force-with-lease" ) for arg in args
523+ ):
524+ return "git push --force"
525+ if subcommand == "reset" and "--hard" in args :
526+ return "git reset --hard"
527+ if subcommand == "clean" and any (
528+ arg == "--force" or "f" in _short_flag_letters (arg ) for arg in args
529+ ):
530+ return "git clean -f"
531+ return None
532+ # Inline-code interpreters are opaque to the token parser -> deliberate.
533+ if base in _OPAQUE_INTERPRETERS and any (arg in _INLINE_CODE_FLAGS for arg in args ):
534+ return f"opaque inline code via { base } "
535+ return None
0 commit comments