@@ -138,49 +138,53 @@ def _json_bytes(value: Any) -> bytes:
138138 return json .dumps (value , ensure_ascii = False , sort_keys = True , separators = ("," , ":" )).encode ("utf-8" )
139139
140140
141+ def _resolve_user_owned_path (raw_path : str , code : str , label : str ) -> pathlib .Path :
142+ """Resolve a local path and confine it to the user's home or temp tree."""
143+
144+ expanded = os .path .expandvars (os .path .expanduser (raw_path ))
145+ normalized = os .path .normcase (os .path .realpath (expanded ))
146+ allowed_roots = (
147+ os .path .normcase (os .path .realpath (str (pathlib .Path .home ()))),
148+ os .path .normcase (os .path .realpath (tempfile .gettempdir ())),
149+ )
150+ for allowed_root in allowed_roots :
151+ prefix = allowed_root .rstrip (os .sep ) + os .sep
152+ if normalized .startswith (prefix ):
153+ return pathlib .Path (normalized )
154+ raise BridgeError (code , "{} must be inside the current user's home or temporary directory." .format (label ))
155+
156+
141157def _state_root () -> pathlib .Path :
142158 configured = os .environ .get (STATE_DIR_ENV )
143159 if configured :
144- return pathlib . Path ( os . path . expandvars ( os . path . expanduser ( configured ))). resolve ( )
160+ return _resolve_user_owned_path ( configured , "invalid_config" , "The ROS Agent state directory" )
145161 return pathlib .Path (os .path .expanduser ("~/.cache/alicloud-ros-agent" )).resolve ()
146162
147163
148164def _secure_directory (path : pathlib .Path ) -> None :
149- # The bridge only calls this with its process-local state root or a child
150- # path derived from a validated job identifier.
151- # codeql[py/path-injection]
152165 path .mkdir (parents = True , exist_ok = True )
153166 if os .name != "nt" :
154- # codeql[py/path-injection]
155167 os .chmod (str (path ), 0o700 )
156168
157169
158170def _atomic_json (path : pathlib .Path , value : Dict [str , Any ], mode : int = 0o600 ) -> None :
159171 _secure_directory (path .parent )
160- # Atomic state files are always beneath the bridge-owned state directory.
161- # codeql[py/path-injection]
162172 descriptor , temporary = tempfile .mkstemp (prefix = path .name + "." , suffix = ".tmp" , dir = str (path .parent ))
163173 try :
164174 with os .fdopen (descriptor , "w" , encoding = "utf-8" ) as handle :
165175 json .dump (value , handle , ensure_ascii = False , sort_keys = True , separators = ("," , ":" ))
166176 handle .flush ()
167177 os .fsync (handle .fileno ())
168178 if os .name != "nt" :
169- # codeql[py/path-injection]
170179 os .chmod (temporary , mode )
171- # codeql[py/path-injection]
172180 os .replace (temporary , str (path ))
173181 finally :
174182 with contextlib .suppress (OSError ):
175- # codeql[py/path-injection]
176183 os .unlink (temporary )
177184
178185
179186def _load_state_json (path : pathlib .Path , code : str = "job_not_found" ) -> Dict [str , Any ]:
180187 try :
181- # Callers pass only bridge state paths derived from validated local
182- # identifiers; remote StartChat payloads cannot select this path.
183- # codeql[py/path-injection]
184188 with path .open ("r" , encoding = "utf-8" ) as handle :
185189 value = json .load (handle )
186190 except (OSError , ValueError ) as exc :
@@ -451,24 +455,22 @@ def sanitize_text(value: Any, maximum: int = 4000, preserve_lines: bool = False)
451455
452456
453457def _workspace (raw_path : Optional [str ] = None ) -> pathlib .Path :
454- # The optional value comes only from the authenticated loopback manager;
455- # existence and directory type are checked before it is used.
456- # codeql[py/path-injection]
457- path = pathlib .Path (raw_path or os .getcwd ()).expanduser ().resolve ()
458+ path = (
459+ _resolve_user_owned_path (raw_path , "invalid_input" , "The workspace" )
460+ if raw_path is not None
461+ else pathlib .Path .cwd ().resolve ()
462+ )
458463 if not path .is_dir ():
459464 raise BridgeError ("invalid_input" , "The workspace must be an existing directory." )
460465 return path
461466
462467
463468def _read_workspace_file (workspace : pathlib .Path , raw_path : str , maximum : int , label : str ) -> str :
464- # The resolved path is rejected below unless it remains inside the already
465- # validated workspace. Constructing it alone performs no filesystem read.
466- # codeql[py/path-injection]
467- path = pathlib .Path (raw_path ).expanduser ().resolve ()
468- try :
469- path .relative_to (workspace )
470- except ValueError as exc :
471- raise BridgeError ("invalid_input" , "{} must be inside the workspace." .format (label )) from exc
469+ workspace_path = os .path .normcase (os .path .realpath (str (workspace )))
470+ resolved_path = os .path .normcase (os .path .realpath (os .path .expanduser (raw_path )))
471+ if not resolved_path .startswith (workspace_path .rstrip (os .sep ) + os .sep ):
472+ raise BridgeError ("invalid_input" , "{} must be inside the workspace." .format (label ))
473+ path = pathlib .Path (resolved_path )
472474 try :
473475 data = path .read_bytes ()
474476 except OSError as exc :
@@ -594,18 +596,10 @@ def build_permission_query(
594596
595597def resolve_aliyun (raw_path : str ) -> str :
596598 expanded = os .path .expanduser (raw_path )
597- if os .path .dirname (expanded ):
598- path = os .path .abspath (expanded )
599- # An explicit CLI path is local installation policy. It is checked as
600- # a regular file and later executed without a shell.
601- # codeql[py/path-injection]
602- if not os .path .isfile (path ):
603- raise BridgeError ("cli_not_found" , "Alibaba Cloud CLI was not found at the requested path." )
604- return path
605599 resolved = shutil .which (expanded )
606600 if not resolved :
607601 raise BridgeError ("cli_not_found" , "Alibaba Cloud CLI is not installed or is not on PATH." )
608- return resolved
602+ return os . path . abspath ( resolved )
609603
610604
611605def build_start_chat_parameters (
@@ -2407,11 +2401,8 @@ def _finish_job(
24072401 if isinstance (job .get (key ), str ):
24082402 boundary [key ] = job [key ]
24092403 data = _json_bytes (boundary ) + b"\n "
2410- # The spool belongs to a validated job under the bridge state root.
2411- # codeql[py/path-injection]
24122404 current_size = spool .stat ().st_size if spool .exists () else 0
24132405 if current_size + len (data ) <= MAX_SPOOL_BYTES :
2414- # codeql[py/path-injection]
24152406 with spool .open ("ab" ) as handle :
24162407 handle .write (data )
24172408 handle .flush ()
@@ -2583,12 +2574,9 @@ def _fail_sideband_job(
25832574
25842575
25852576def _read_spool (spool : pathlib .Path ) -> List [Dict [str , Any ]]:
2586- # Spool paths are produced only by _job_paths after job-id validation.
2587- # codeql[py/path-injection]
25882577 if not spool .exists ():
25892578 return []
25902579 values = []
2591- # codeql[py/path-injection]
25922580 with spool .open ("r" , encoding = "utf-8" ) as handle :
25932581 for line in handle :
25942582 try :
@@ -2631,18 +2619,14 @@ def _follow_timeout_result(job_id: str, start_cursor: int) -> Optional[Dict[str,
26312619 "time" : int (time .time ()),
26322620 }
26332621 data = _json_bytes (marker ) + b"\n "
2634- # The spool belongs to a validated job under the bridge state root.
2635- # codeql[py/path-injection]
26362622 current_size = spool .stat ().st_size if spool .exists () else 0
26372623 if current_size + len (data ) > MAX_SPOOL_BYTES :
26382624 raise BridgeError ("stream_failed" , "The bounded ROS Agent event spool is full." )
2639- # codeql[py/path-injection]
26402625 with spool .open ("ab" ) as handle :
26412626 handle .write (data )
26422627 handle .flush ()
26432628 os .fsync (handle .fileno ())
26442629 if os .name != "nt" :
2645- # codeql[py/path-injection]
26462630 os .chmod (str (spool ), 0o600 )
26472631 return _job_result (
26482632 job_id ,
@@ -3127,35 +3111,28 @@ def _stop_process(process: Any) -> None:
31273111
31283112def _spawn_worker (job_id : str , request : Dict [str , Any ]) -> int :
31293113 root , job_path , _spool = _job_paths (job_id )
3130- request_path = root / ("request-{}.json" .format (uuid .uuid4 ().hex ))
3114+ request_token = uuid .uuid4 ().hex
3115+ request_path = root / ("request-{}.json" .format (request_token ))
31313116 _atomic_json (request_path , request )
3117+ canonical_job_id = uuid .UUID (job_id ).hex
31323118 command = [
31333119 sys .executable ,
31343120 str (pathlib .Path (__file__ ).resolve ()),
31353121 "_worker" ,
31363122 "--job-id" ,
3137- job_id ,
3138- "--request-file " ,
3139- str ( request_path ) ,
3123+ canonical_job_id ,
3124+ "--request-token " ,
3125+ request_token ,
31403126 ]
31413127 log_path = root / "worker.log"
31423128 creationflags = getattr (subprocess , "CREATE_NEW_PROCESS_GROUP" , 0 ) if os .name == "nt" else 0
3143- # Worker paths are generated beneath a validated job directory and the
3144- # launched argv fixes both the interpreter and this bridge script.
3145- # codeql[py/path-injection]
31463129 if log_path .exists () and log_path .stat ().st_size > MAX_DIAGNOSTIC_BYTES :
3147- # codeql[py/path-injection]
31483130 with log_path .open ("wb" ):
31493131 pass
31503132 try :
3151- # codeql[py/path-injection]
31523133 with log_path .open ("ab" , buffering = 0 ) as log :
31533134 if os .name != "nt" :
3154- # codeql[py/path-injection]
31553135 os .chmod (str (log_path ), 0o600 )
3156- # command is a fixed interpreter/script pair plus a validated job
3157- # id and a bridge-generated request filename; shell=False is used.
3158- # codeql[py/command-line-injection]
31593136 process = subprocess .Popen (
31603137 command ,
31613138 stdin = subprocess .DEVNULL ,
@@ -3166,7 +3143,6 @@ def _spawn_worker(job_id: str, request: Dict[str, Any]) -> int:
31663143 )
31673144 except OSError as exc :
31683145 with contextlib .suppress (OSError ):
3169- # codeql[py/path-injection]
31703146 request_path .unlink ()
31713147 request_seq = int (request .get ("requestSeq" ) or 0 )
31723148 error = BridgeError ("worker_start_failed" , "The StartChat worker could not be started." , True )
@@ -3631,8 +3607,16 @@ def _cancel_job_local(payload: Dict[str, Any]) -> Dict[str, Any]:
36313607 return result
36323608
36333609
3634- def run_worker (job_id : str , request_file : str ) -> int :
3635- request_path = pathlib .Path (request_file ).resolve ()
3610+ def run_worker (job_id : str , request_token : str ) -> int :
3611+ try :
3612+ canonical_job_id = uuid .UUID (job_id ).hex
3613+ canonical_request_token = uuid .UUID (request_token ).hex
3614+ except (AttributeError , ValueError ) as exc :
3615+ raise BridgeError ("invalid_input" , "The worker launch capability is invalid." ) from exc
3616+ if canonical_job_id != job_id or canonical_request_token != request_token :
3617+ raise BridgeError ("invalid_input" , "The worker launch capability is invalid." )
3618+ root , _job_path , _spool = _job_paths (canonical_job_id )
3619+ request_path = root / ("request-{}.json" .format (canonical_request_token ))
36363620 request = _load_state_json (request_path , "invalid_input" )
36373621 with contextlib .suppress (OSError ):
36383622 request_path .unlink ()
@@ -4262,7 +4246,7 @@ def build_parser() -> argparse.ArgumentParser:
42624246 server .add_argument ("--record-file" , required = True )
42634247 worker = subparsers .add_parser ("_worker" , help = argparse .SUPPRESS )
42644248 worker .add_argument ("--job-id" , required = True )
4265- worker .add_argument ("--request-file " , required = True )
4249+ worker .add_argument ("--request-token " , required = True )
42664250 return parser
42674251
42684252
@@ -4276,7 +4260,7 @@ def main(argv: Optional[List[str]] = None) -> int:
42764260 if args .command == "_server" :
42774261 return run_manager_server (args .record_file )
42784262 if args .command == "_worker" :
4279- return run_worker (args .job_id , args .request_file )
4263+ return run_worker (args .job_id , args .request_token )
42804264 apply_skill_config (args , load_skill_config ())
42814265 if args .command == "check" :
42824266 result = run_check (args )
0 commit comments