66import re
77from dataclasses import dataclass
88from pathlib import Path
9+ from urllib .parse import urlparse
910
1011from .models import DiffInput
1112from .models import FilterDecision
3738 r":\(\)\s*\{" ,
3839)
3940
41+ NETWORK_COMMAND_PATTERNS = (
42+ r"\b(pip|pip3|python(?:3)?\s+-m\s+pip)\s+install\b" ,
43+ r"\b(npm|pnpm|yarn)\s+(install|add|ci)\b" ,
44+ r"\b(go\s+get|cargo\s+install)\b" ,
45+ r"\b(git)\s+(clone|fetch|pull|submodule\s+update)\b" ,
46+ r"\b(ssh|scp|sftp|rsync)\b" ,
47+ r"\b(apt-get|apt|apk|yum|dnf|brew)\s+(install|update|upgrade)\b" ,
48+ )
49+
4050
4151@dataclass (slots = True )
4252class SandboxRequest :
@@ -66,6 +76,7 @@ def __init__(
6676 sandbox_path_allowlist : tuple [str , ...] = ("scripts/" , "work/" ),
6777 sandbox_read_allowlist : tuple [str , ...] = ("scripts/" , "work/" , "repo/" ),
6878 sandbox_write_allowlist : tuple [str , ...] = ("work/" , ),
79+ network_command_patterns : tuple [str , ...] = NETWORK_COMMAND_PATTERNS ,
6980 schema_version : int = 1 ,
7081 ):
7182 self .network_policy = network_policy
@@ -77,6 +88,7 @@ def __init__(
7788 self .sandbox_path_allowlist = sandbox_path_allowlist
7889 self .sandbox_read_allowlist = sandbox_read_allowlist
7990 self .sandbox_write_allowlist = sandbox_write_allowlist
91+ self .network_command_patterns = network_command_patterns
8092 self .schema_version = schema_version
8193 self .last_redaction_count = 0
8294
@@ -102,6 +114,7 @@ def load(
102114 sandbox_path_allowlist = tuple (data .get ("sandbox_path_allowlist" , ("scripts/" , "work/" ))),
103115 sandbox_read_allowlist = tuple (data .get ("sandbox_read_allowlist" , ("scripts/" , "work/" , "repo/" ))),
104116 sandbox_write_allowlist = tuple (data .get ("sandbox_write_allowlist" , ("work/" , ))),
117+ network_command_patterns = tuple (data .get ("network_command_patterns" , NETWORK_COMMAND_PATTERNS )),
105118 schema_version = int (data .get ("schema_version" , 1 )),
106119 )
107120
@@ -117,6 +130,7 @@ def audit(self) -> dict[str, object]:
117130 "sandbox_path_allowlist" : list (self .sandbox_path_allowlist ),
118131 "sandbox_read_allowlist" : list (self .sandbox_read_allowlist ),
119132 "sandbox_write_allowlist" : list (self .sandbox_write_allowlist ),
133+ "network_command_patterns" : list (self .network_command_patterns ),
120134 }
121135
122136 def evaluate (
@@ -241,6 +255,41 @@ def _evaluate_request(self, request: SandboxRequest) -> FilterDecision:
241255 policy = "high-risk-command" ,
242256 severity = "high" ,
243257 )
258+ command_domains = _network_domains_in_command (command_to_check )
259+ if command_domains :
260+ if self .network_policy != "allowlist" :
261+ return self ._decision (
262+ decision = "needs_human_review" ,
263+ reason = "Command references network domains while the active review policy denies network access: "
264+ f"{ ', ' .join (command_domains )} ." ,
265+ command = command_to_check ,
266+ path = request .script_path ,
267+ policy = "network-command" ,
268+ severity = "high" ,
269+ )
270+ disallowed = sorted (set (command_domains ) - set (self .allowed_network_domains ))
271+ if disallowed :
272+ return self ._decision (
273+ decision = "needs_human_review" ,
274+ reason = f"Command references network domains outside the configured allowlist: "
275+ f"{ ', ' .join (disallowed )} ." ,
276+ command = command_to_check ,
277+ path = request .script_path ,
278+ policy = "network-command-allowlist" ,
279+ severity = "high" ,
280+ )
281+ if not command_domains and _is_network_command (command_to_check , self .network_command_patterns ):
282+ return self ._decision (
283+ decision = "needs_human_review" ,
284+ reason = (
285+ "Command appears to require network access but does not declare reviewable allowlisted "
286+ "network domains."
287+ ),
288+ command = command_to_check ,
289+ path = request .script_path ,
290+ policy = "network-command-implicit" ,
291+ severity = "high" ,
292+ )
244293 return self ._decision (
245294 decision = "allow" ,
246295 reason = (
@@ -283,7 +332,20 @@ def _is_forbidden_path(path: str, markers: tuple[str, ...] = FORBIDDEN_PATH_MARK
283332
284333
285334def _is_high_risk_command (command : str , patterns : tuple [str , ...] = HIGH_RISK_COMMAND_PATTERNS ) -> bool :
286- return any (re .search (pattern , command ) for pattern in patterns )
335+ return any (re .search (pattern , command , re .IGNORECASE ) for pattern in patterns )
336+
337+
338+ def _network_domains_in_command (command : str ) -> tuple [str , ...]:
339+ domains : set [str ] = set ()
340+ for match in re .finditer (r"https?://[^\s'\"),]+" , command , re .IGNORECASE ):
341+ parsed = urlparse (match .group (0 ))
342+ if parsed .hostname :
343+ domains .add (parsed .hostname .lower ())
344+ return tuple (sorted (domains ))
345+
346+
347+ def _is_network_command (command : str , patterns : tuple [str , ...] = NETWORK_COMMAND_PATTERNS ) -> bool :
348+ return any (re .search (pattern , command , re .IGNORECASE ) for pattern in patterns )
287349
288350
289351def _path_is_allowed (path : str , allowlist : tuple [str , ...]) -> bool :
0 commit comments