Skip to content

Commit 87eb293

Browse files
fix: address code review — server-side clamp, JobCreationParams, name validation
Critical: 1. Server-side max_iterations clamp in create_job_inner — defense no longer relies solely on tool parameter parsing. Uses MAX_WORKER_ITERATIONS constant (matching worker/job.rs) so the cap is enforced even for direct API calls. 2. Introduce JobCreationParams struct to bundle credential_grants, mcp_servers, and max_iterations. Removes #[allow(clippy::too_many_arguments)] from both create_job and execute_sandbox (7→5 and 9→7 positional args). Important: 3. Validate MCP server names: reject path separators (/\), null bytes, and names longer than 128 chars to prevent future misuse. 5. Add test verifying max_iterations is NOT injected for ClaudeCode mode. Add test verifying server-side clamp uses MAX_WORKER_ITERATIONS constant. Add test verifying name validation rejects path separators and null bytes.
1 parent c8bc9d7 commit 87eb293

3 files changed

Lines changed: 106 additions & 33 deletions

File tree

src/channels/web/handlers/jobs.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,9 +448,10 @@ pub async fn jobs_restart_handler(
448448
&task,
449449
Some(project_dir),
450450
mode,
451-
credential_grants,
452-
None,
453-
None,
451+
crate::orchestrator::job_manager::JobCreationParams {
452+
credential_grants,
453+
..Default::default()
454+
},
454455
)
455456
.await
456457
.map_err(|e| {

src/orchestrator/job_manager.rs

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ use crate::sandbox::connect_docker;
1919
/// Path to the master worker MCP config on the host.
2020
const 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)]
2429
pub 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)]
4866
pub 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\0evil".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]

src/tools/builtin/job.rs

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use crate::context::{ContextManager, JobContext, JobState};
2121
use crate::db::Database;
2222
use crate::history::SandboxJobRecord;
2323
use crate::orchestrator::auth::CredentialGrant;
24-
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
24+
use crate::orchestrator::job_manager::{ContainerJobManager, JobCreationParams, JobMode};
2525
use crate::secrets::SecretsStore;
2626
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
2727
use ironclaw_common::AppEvent;
@@ -355,16 +355,13 @@ impl CreateJobTool {
355355
}
356356

357357
/// Execute via sandboxed Docker container.
358-
#[allow(clippy::too_many_arguments)]
359358
async fn execute_sandbox(
360359
&self,
361360
task: &str,
362361
explicit_dir: Option<PathBuf>,
363362
wait: bool,
364363
mode: JobMode,
365-
credential_grants: Vec<CredentialGrant>,
366-
mcp_servers: Option<Vec<String>>,
367-
max_iterations: Option<u32>,
364+
params: JobCreationParams,
368365
ctx: &JobContext,
369366
) -> Result<ToolOutput, ToolError> {
370367
let start = std::time::Instant::now();
@@ -379,7 +376,7 @@ impl CreateJobTool {
379376
let project_dir_str = project_dir.display().to_string();
380377

381378
// Serialize credential grants so restarts can reload them.
382-
let credential_grants_json = match serde_json::to_string(&credential_grants) {
379+
let credential_grants_json = match serde_json::to_string(&params.credential_grants) {
383380
Ok(json) => json,
384381
Err(e) => {
385382
tracing::warn!(
@@ -434,15 +431,7 @@ impl CreateJobTool {
434431

435432
// Create the container job with the pre-determined job_id.
436433
let _token = jm
437-
.create_job(
438-
job_id,
439-
task,
440-
Some(project_dir),
441-
mode,
442-
credential_grants,
443-
mcp_servers,
444-
max_iterations,
445-
)
434+
.create_job(job_id, task, Some(project_dir), mode, params)
446435
.await
447436
.map_err(|e| {
448437
self.update_status(
@@ -962,9 +951,11 @@ impl Tool for CreateJobTool {
962951
explicit_dir,
963952
wait,
964953
mode,
965-
credential_grants,
966-
mcp_servers,
967-
max_iterations,
954+
JobCreationParams {
955+
credential_grants,
956+
mcp_servers,
957+
max_iterations,
958+
},
968959
ctx,
969960
)
970961
.await
@@ -1623,9 +1614,7 @@ mod tests {
16231614
None,
16241615
false,
16251616
JobMode::Worker,
1626-
vec![],
1627-
None,
1628-
None,
1617+
JobCreationParams::default(),
16291618
&JobContext::default(),
16301619
)
16311620
.await;

0 commit comments

Comments
 (0)