@@ -19,6 +19,11 @@ use crate::sandbox::connect_docker;
1919/// Path to the master worker MCP config on the host.
2020const WORKER_MCP_CONFIG_PATH : & str = "/opt/ironclaw/config/worker/mcp-servers.json" ;
2121
22+ /// Maximum worker agent loop iterations. Must match `MAX_WORKER_ITERATIONS` in
23+ /// `src/worker/job.rs`. Applied server-side in `create_job_inner` so that even
24+ /// a direct API call (bypassing tool parameter parsing) is capped.
25+ const MAX_WORKER_ITERATIONS : u32 = 500 ;
26+
2227/// Which mode a sandbox container runs in.
2328#[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
2429pub enum JobMode {
@@ -43,6 +48,19 @@ impl std::fmt::Display for JobMode {
4348 }
4449}
4550
51+ /// Parameters for creating a container job, bundled to avoid positional
52+ /// argument proliferation on `create_job` / `execute_sandbox`.
53+ #[ derive( Debug , Clone , Default ) ]
54+ pub struct JobCreationParams {
55+ /// Credential grants for the worker (served via `/credentials`).
56+ pub credential_grants : Vec < CredentialGrant > ,
57+ /// Optional filter: which MCP servers to mount into the container.
58+ /// `None` = full master config, `Some([])` = no MCP, `Some(["name"])` = filtered.
59+ pub mcp_servers : Option < Vec < String > > ,
60+ /// Optional cap on worker agent loop iterations (clamped to 1..=500 server-side).
61+ pub max_iterations : Option < u32 > ,
62+ }
63+
4664/// Configuration for the container job manager.
4765#[ derive( Debug , Clone ) ]
4866pub struct ContainerJobConfig {
@@ -255,23 +273,20 @@ impl ContainerJobManager {
255273 /// before the container is created. Credential grants are stored in the
256274 /// TokenStore and served on-demand via the `/credentials` endpoint.
257275 /// Returns the auth token for the worker.
258- #[ allow( clippy:: too_many_arguments) ]
259276 pub async fn create_job (
260277 & self ,
261278 job_id : Uuid ,
262279 task : & str ,
263280 project_dir : Option < PathBuf > ,
264281 mode : JobMode ,
265- credential_grants : Vec < CredentialGrant > ,
266- mcp_servers : Option < Vec < String > > ,
267- max_iterations : Option < u32 > ,
282+ params : JobCreationParams ,
268283 ) -> Result < String , OrchestratorError > {
269284 // Generate auth token (stored in TokenStore, never logged)
270285 let token = self . token_store . create_token ( job_id) . await ;
271286
272287 // Store credential grants (revoked automatically when the token is revoked)
273288 self . token_store
274- . store_grants ( job_id, credential_grants)
289+ . store_grants ( job_id, params . credential_grants )
275290 . await ;
276291
277292 // Record the handle
@@ -297,8 +312,8 @@ impl ContainerJobManager {
297312 & token,
298313 project_dir,
299314 mode,
300- mcp_servers,
301- max_iterations,
315+ params . mcp_servers ,
316+ params . max_iterations ,
302317 )
303318 . await
304319 {
@@ -312,7 +327,6 @@ impl ContainerJobManager {
312327 }
313328
314329 /// Inner implementation of container creation (separated for cleanup).
315- #[ allow( clippy:: too_many_arguments) ]
316330 async fn create_job_inner (
317331 & self ,
318332 job_id : Uuid ,
@@ -352,10 +366,13 @@ impl ContainerJobManager {
352366 }
353367
354368 // Inject max_iterations if specified (only for Worker mode — ClaudeCode uses max_turns).
369+ // Server-side clamp ensures the cap is enforced even if the tool parsing
370+ // layer is bypassed (e.g., direct API call via the web restart handler).
355371 if let Some ( iters) = max_iterations
356372 && mode == JobMode :: Worker
357373 {
358- env_vec. push ( format ! ( "IRONCLAW_MAX_ITERATIONS={}" , iters) ) ;
374+ let capped = iters. clamp ( 1 , MAX_WORKER_ITERATIONS ) ;
375+ env_vec. push ( format ! ( "IRONCLAW_MAX_ITERATIONS={}" , capped) ) ;
359376 }
360377
361378 // Mount per-job MCP config when the feature is enabled.
@@ -705,6 +722,22 @@ fn generate_worker_mcp_config(
705722
706723 // Filter to specific servers
707724 Some ( names) => {
725+ // Validate server names: reject path separators, null bytes, and
726+ // excessively long names to prevent misuse if names are ever used
727+ // in file paths or shell commands.
728+ for name in names {
729+ if name. len ( ) > 128
730+ || name. contains ( '/' )
731+ || name. contains ( '\\' )
732+ || name. contains ( '\0' )
733+ {
734+ return Err ( OrchestratorError :: ContainerCreationFailed {
735+ job_id,
736+ reason : format ! ( "invalid MCP server name: {:?}" , name) ,
737+ } ) ;
738+ }
739+ }
740+
708741 let content = std:: fs:: read_to_string ( master_path) . map_err ( |e| {
709742 OrchestratorError :: ContainerCreationFailed {
710743 job_id,
@@ -991,6 +1024,56 @@ mod tests {
9911024 drop ( mgr) ;
9921025 }
9931026
1027+ #[ test]
1028+ fn test_max_iterations_not_injected_for_claude_code ( ) {
1029+ // ClaudeCode mode uses its own `max_turns`, not IRONCLAW_MAX_ITERATIONS.
1030+ // Verify the gate in create_job_inner only injects for Worker mode.
1031+ let source = include_str ! ( "job_manager.rs" ) ;
1032+ assert ! (
1033+ source. contains( "mode == JobMode::Worker" ) ,
1034+ "create_job_inner must gate IRONCLAW_MAX_ITERATIONS on JobMode::Worker \
1035+ (ClaudeCode has its own max_turns)"
1036+ ) ;
1037+ }
1038+
1039+ #[ test]
1040+ fn test_server_side_max_iterations_clamp ( ) {
1041+ // Verify the server-side clamp uses the same constant as worker/job.rs
1042+ let source = include_str ! ( "job_manager.rs" ) ;
1043+ assert ! (
1044+ source. contains( "iters.clamp(1, MAX_WORKER_ITERATIONS)" ) ,
1045+ "create_job_inner must clamp max_iterations server-side using MAX_WORKER_ITERATIONS"
1046+ ) ;
1047+ }
1048+
1049+ #[ test]
1050+ fn test_mcp_server_name_validation_rejects_path_separators ( ) {
1051+ let job_id = Uuid :: new_v4 ( ) ;
1052+ let tmp = tempfile:: NamedTempFile :: new ( ) . unwrap ( ) ;
1053+ std:: fs:: write (
1054+ tmp. path ( ) ,
1055+ r#"{"servers":[{"name":"test","enabled":true}]}"# ,
1056+ )
1057+ . unwrap ( ) ;
1058+
1059+ // Path separator should be rejected
1060+ let names = vec ! [ "../../etc/passwd" . to_string( ) ] ;
1061+ assert ! ( generate_worker_mcp_config( tmp. path( ) , Some ( & names) , job_id) . is_err( ) ) ;
1062+
1063+ // Null byte should be rejected
1064+ let names = vec ! [ "test\0 evil" . to_string( ) ] ;
1065+ assert ! ( generate_worker_mcp_config( tmp. path( ) , Some ( & names) , job_id) . is_err( ) ) ;
1066+
1067+ // Excessively long name should be rejected
1068+ let names = vec ! [ "a" . repeat( 129 ) ] ;
1069+ assert ! ( generate_worker_mcp_config( tmp. path( ) , Some ( & names) , job_id) . is_err( ) ) ;
1070+
1071+ // Valid name should pass
1072+ let names = vec ! [ "test" . to_string( ) ] ;
1073+ let result = generate_worker_mcp_config ( tmp. path ( ) , Some ( & names) , job_id) ;
1074+ assert ! ( result. is_ok( ) ) ;
1075+ }
1076+
9941077 // ── Regression tests (CI-required) ────────────────────────────────
9951078
9961079 #[ test]
0 commit comments