1111import re
1212import shlex
1313from collections .abc import Sequence
14+ from typing import Protocol
1415
1516_INTERPRETER_RE = re .compile (
1617 r"(?:^|&&|\|\||;)\s*(?:env\s+)?(?:python3?|perl|ruby|node)\s+"
7475 re .DOTALL ,
7576)
7677
78+ _PROTECTED_PATH_METADATA_GIT_SUBCOMMANDS : frozenset [str ] = frozenset ({"add" , "diff" , "status" })
79+ # Command wrappers whose only effect is to invoke the next command with
80+ # adjusted environment/priority. The verb is the token after the wrapper.
81+ # 'xargs' is intentionally excluded: it dispatches a downstream reader and
82+ # adding it would let `xargs cat src/.../foo.yaml` reach the reader check,
83+ # weakening xargs-chain bypass detection (see D1 design decision).
84+ _COMMAND_WRAPPERS : frozenset [str ] = frozenset ({"command" , "nice" , "time" , "sudo" , "nohup" })
85+ # Wrappers that consume a mandatory DURATION as their first non-wrapper token.
86+ _WRAPPERS_WITH_DURATION : frozenset [str ] = frozenset ({"timeout" })
87+ # Wrappers that take a single short flag as their first non-wrapper token
88+ # (e.g. 'stdbuf -o0', 'stdbuf -i0', 'stdbuf -e0').
89+ _WRAPPERS_WITH_SHORT_FLAG : frozenset [str ] = frozenset ({"stdbuf" })
90+ _GIT_ADD_CONTENT_FLAGS : frozenset [str ] = frozenset (
91+ {
92+ "-p" ,
93+ "--patch" ,
94+ "-e" ,
95+ "--edit" ,
96+ "-i" ,
97+ "--interactive" ,
98+ "--pathspec-from-file" ,
99+ # Content-staging flags: -A/--all stages all changes (incl. content);
100+ # --force/--no-ignore-removal/--no-all are the no-restriction variants.
101+ # Without these, `git add -A -- src/.../foo.yaml` is classified as
102+ # metadata but actually stages content for indirect read via
103+ # `git diff --staged`.
104+ "-A" ,
105+ "--all" ,
106+ "--force" ,
107+ "--no-ignore-removal" ,
108+ "--no-all" ,
109+ }
110+ )
111+ _GIT_STATUS_CONTENT_FLAGS : frozenset [str ] = frozenset ({"-v" , "--verbose" })
112+ _GIT_DIFF_CONTENT_FLAGS : frozenset [str ] = frozenset (
113+ {
114+ "-p" ,
115+ "--patch" ,
116+ "--patch-with-stat" ,
117+ "--patch-with-raw" ,
118+ "--binary" ,
119+ "--text" ,
120+ "--word-diff" ,
121+ "--color-words" ,
122+ }
123+ )
124+ _GIT_DIFF_METADATA_FLAGS : frozenset [str ] = frozenset (
125+ {
126+ "--name-only" ,
127+ "--name-status" ,
128+ "--stat" ,
129+ "--shortstat" ,
130+ "--numstat" ,
131+ "--summary" ,
132+ }
133+ )
134+ _SHELL_SUBSTITUTION_RE = re .compile (r"\$\(|`|[<>]\(" )
135+ _SHELL_STATE_VAR_RE = re .compile (r"\$(?:_|[A-Za-z][A-Za-z0-9_]*|\{[^}]+\})" )
136+ _PROTECTED_READ_SHELL_OPS : frozenset [str ] = frozenset ({"&&" , "||" , ";" , "|" , "&" })
137+ _WC_FLAG_RE = re .compile (r"-l+|--lines$" )
138+
139+
140+ class SearchPattern (Protocol ):
141+ def search (self , string : str , / ): ...
142+
77143
78144def strip_heredoc_bodies (command : str ) -> str :
79145 """Strip heredoc body content, preserving the opening line and terminator.
@@ -195,16 +261,46 @@ def extract_redirect_targets(tokens: list[str], cwd: str = "") -> list[str]:
195261 return targets
196262
197263
198- def command_verb (segment : list [str ]) -> str :
199- """Return the command verb from a segment, skipping 'env' prefix."""
264+ def _command_start_index (segment : list [str ]) -> int | None :
200265 if not segment :
201- return ""
266+ return None
202267 start = 0
203- if segment [0 ] == "env" and len (segment ) > 1 :
204- start = 1
205- while start < len (segment ) and (segment [start ].startswith ("-" ) or "=" in segment [start ]):
268+ # Iteratively strip 'env', command wrappers, and any wrapper-required
269+ # argument. This handles nested forms like
270+ # 'env FOO=BAR command git diff' and 'command env FOO=BAR git diff'
271+ # consistently — the real command verb is the first token that is not a
272+ # known env/wrapper prefix. 'xargs' is intentionally not a wrapper
273+ # (see _COMMAND_WRAPPERS docstring).
274+ while start < len (segment ):
275+ token = segment [start ]
276+ if token == "env" and start + 1 < len (segment ):
206277 start += 1
207- return segment [start ] if start < len (segment ) else ""
278+ while start < len (segment ) and (
279+ segment [start ].startswith ("-" ) or "=" in segment [start ]
280+ ):
281+ start += 1
282+ continue
283+ if token in _COMMAND_WRAPPERS :
284+ start += 1
285+ continue
286+ if token in _WRAPPERS_WITH_DURATION and start + 1 < len (segment ):
287+ start += 2
288+ continue
289+ if (
290+ token in _WRAPPERS_WITH_SHORT_FLAG
291+ and start + 1 < len (segment )
292+ and segment [start + 1 ].startswith ("-" )
293+ ):
294+ start += 2
295+ continue
296+ break
297+ return start if start < len (segment ) else None
298+
299+
300+ def command_verb (segment : list [str ]) -> str :
301+ """Return the command verb from a segment, skipping 'env' prefix."""
302+ start = _command_start_index (segment )
303+ return segment [start ] if start is not None else ""
208304
209305
210306def is_gh_command (segment : list [str ]) -> bool :
@@ -233,12 +329,13 @@ def extract_git_subcommand_and_flags(
233329 then returns (subcommand, remaining_tokens). Returns None if the segment
234330 is not a git command or has no subcommand.
235331 """
236- if not segment :
332+ start = _command_start_index (segment )
333+ if start is None :
237334 return None
238- verb = segment [0 ]
335+ verb = segment [start ]
239336 if verb != "git" and not verb .endswith ("/git" ):
240337 return None
241- i = 1
338+ i = start + 1
242339 while i < len (segment ):
243340 token = segment [i ]
244341 if token in _GIT_GLOBAL_FLAGS_WITH_VALUE :
@@ -257,6 +354,110 @@ def extract_git_subcommand_and_flags(
257354 return None
258355
259356
357+ def _is_allowed_wc_flag (token : str ) -> bool :
358+ """Return True when *token* is a wc flag that does not reveal file contents.
359+
360+ Allows ``-l``, repeated ``-l`` (e.g. ``-ll``), and the long form ``--lines``
361+ only. Any value-bearing variant (``--lines=10``) or compound form
362+ (``-lL``) is rejected because those are not used for metadata-only reads.
363+ """
364+ return bool (_WC_FLAG_RE .fullmatch (token ))
365+
366+
367+ def is_allowed_protected_path_metadata_command (segment : list [str ]) -> bool :
368+ """Return True for protected-path commands that inspect metadata or VCS state.
369+
370+ Protected recipe/skill/agent paths are normally deny-by-default because most
371+ commands that mention them are content reads. These narrow exceptions support
372+ legitimate pipeline work on files already in scope.
373+ """
374+ verb = command_verb (segment )
375+ if verb == "git" or verb .endswith ("/git" ):
376+ git_parts = extract_git_subcommand_and_flags (segment )
377+ if git_parts is None :
378+ return False
379+ subcommand , flags = git_parts
380+ if subcommand not in _PROTECTED_PATH_METADATA_GIT_SUBCOMMANDS :
381+ return False
382+ if subcommand == "add" :
383+ return not any (
384+ flag in _GIT_ADD_CONTENT_FLAGS or flag .startswith ("--pathspec-from-file=" )
385+ for flag in flags
386+ )
387+ if subcommand == "status" :
388+ return not any (flag in _GIT_STATUS_CONTENT_FLAGS for flag in flags )
389+ if subcommand == "diff" :
390+ if any (
391+ flag in _GIT_DIFF_CONTENT_FLAGS
392+ or flag .startswith ("-U" )
393+ or flag .startswith ("--unified" )
394+ or flag .startswith ("--word-diff" )
395+ or flag .startswith ("--color-words" )
396+ or flag .startswith ("--patch-with-stat" )
397+ or flag .startswith ("--patch-with-raw" )
398+ for flag in flags
399+ ):
400+ return False
401+ return any (
402+ flag in _GIT_DIFF_METADATA_FLAGS or flag .startswith ("--stat=" ) for flag in flags
403+ )
404+ return False
405+ if verb == "wc" or verb .endswith ("/wc" ):
406+ start = _command_start_index (segment )
407+ if start is None :
408+ return False
409+ flags = [token for token in segment [start + 1 :] if token .startswith ("-" )]
410+ return bool (flags ) and all (_is_allowed_wc_flag (token ) for token in flags )
411+ return False
412+
413+
414+ def _tokenize_protected_read_segments (command : str ) -> list [list [str ]]:
415+ try :
416+ lexer = shlex .shlex (command .replace ("\n " , " ; " ), posix = True , punctuation_chars = ";&|()" )
417+ lexer .whitespace_split = True
418+ tokens = list (lexer )
419+ except (ValueError , TypeError ):
420+ return []
421+
422+ segments : list [list [str ]] = []
423+ current : list [str ] = []
424+ for token in tokens :
425+ if token in _PROTECTED_READ_SHELL_OPS :
426+ if current :
427+ segments .append (current )
428+ current = []
429+ else :
430+ current .append (token )
431+ if current :
432+ segments .append (current )
433+ return segments
434+
435+
436+ def command_has_blocked_protected_path_read (
437+ command : str , protected_path_patterns : Sequence [SearchPattern ]
438+ ) -> bool :
439+ """Return True when a command reads a protected recipe/skill/agent path."""
440+ if not any (pattern .search (command ) for pattern in protected_path_patterns ):
441+ return False
442+
443+ if "<<" in command or _SHELL_SUBSTITUTION_RE .search (command ):
444+ return True
445+
446+ segments = _tokenize_protected_read_segments (command )
447+ if not segments :
448+ return True
449+
450+ if len (segments ) > 1 and _SHELL_STATE_VAR_RE .search (command ):
451+ return True
452+
453+ for segment in segments :
454+ segment_text = " " .join (segment )
455+ if any (pattern .search (segment_text ) for pattern in protected_path_patterns ):
456+ if not is_allowed_protected_path_metadata_command (segment ):
457+ return True
458+ return False
459+
460+
260461def has_interpreter_write (command : str ) -> bool :
261462 if not _INTERPRETER_RE .search (command ):
262463 return False
0 commit comments