-
Notifications
You must be signed in to change notification settings - Fork 4.4k
11.2 Scheduler Security
Relevant source files
The following files were used as context for generating this wiki page:
This page documents how the cron job scheduler enforces security policies during job execution. The scheduler applies the same security controls as interactive agent sessions, including autonomy level checks, command allowlists, rate limiting, and path validation. For cron job configuration and scheduling behavior, see Cron Job Configuration. For the broader security architecture across all subsystems, see Security Model.
The scheduler's security model implements defense-in-depth by applying multiple independent checks before executing any shell command. Each scheduled job passes through a five-layer validation pipeline that mirrors the security controls used by the shell tool during interactive sessions.
flowchart TD
Job["Scheduled Job<br/>(CronJob)"] --> Execute["execute_job_with_retry"]
Execute --> CheckType{Job Type?}
CheckType -->|Shell| RunCommand["run_job_command"]
CheckType -->|Agent| RunAgent["run_agent_job"]
RunCommand --> Layer1["Layer 1:<br/>can_act()"]
Layer1 -->|blocked| BlockReadOnly["Return: autonomy is read-only"]
Layer1 -->|pass| Layer2["Layer 2:<br/>is_rate_limited()"]
Layer2 -->|blocked| BlockRate["Return: rate limit exceeded"]
Layer2 -->|pass| Layer3["Layer 3:<br/>is_command_allowed()"]
Layer3 -->|blocked| BlockCommand["Return: command not allowed"]
Layer3 -->|pass| Layer4["Layer 4:<br/>forbidden_path_argument()"]
Layer4 -->|blocked| BlockPath["Return: forbidden path argument"]
Layer4 -->|pass| Layer5["Layer 5:<br/>record_action()"]
Layer5 -->|budget exhausted| BlockBudget["Return: action budget exhausted"]
Layer5 -->|pass| SpawnShell["spawn sh -lc command"]
SpawnShell --> Timeout{Timeout?}
Timeout -->|yes| ReturnTimeout["Return: job timed out"]
Timeout -->|no| ReturnSuccess["Return: job output"]
BlockReadOnly --> CheckRetry{Retryable?}
BlockRate --> CheckRetry
BlockCommand --> CheckRetry
BlockPath --> CheckRetry
BlockBudget --> CheckRetry
CheckRetry -->|no| FinalFail["Final Failure"]
CheckRetry -->|yes| Backoff["Exponential Backoff"]
Backoff --> Execute
ReturnSuccess --> RecordRun["record_run"]
ReturnTimeout --> RecordRun
FinalFail --> RecordRun
Sources: src/cron/scheduler.rs:52-85, src/cron/scheduler.rs:377-467
The scheduler instantiates a SecurityPolicy from the [autonomy] configuration section and shares it across all job executions. This ensures consistent enforcement of security rules regardless of whether actions originate from interactive sessions, scheduled jobs, or webhook handlers.
| Location | Pattern | Scope |
|---|---|---|
| Scheduler Main Loop |
Arc<SecurityPolicy> created once at startup |
Shared across all job executions in parallel |
| Manual Job Execution |
SecurityPolicy created per invocation |
Single-use for execute_job_now calls |
graph TB
Config["Config::autonomy"] --> CreatePolicy["SecurityPolicy::from_config"]
CreatePolicy --> ArcPolicy["Arc<SecurityPolicy>"]
ArcPolicy --> Loop["Scheduler Loop<br/>process_due_jobs"]
Loop --> Job1["Job 1<br/>execute_job_with_retry"]
Loop --> Job2["Job 2<br/>execute_job_with_retry"]
Loop --> JobN["Job N<br/>execute_job_with_retry"]
Job1 --> Check1["run_job_command<br/>security checks"]
Job2 --> Check2["run_job_command<br/>security checks"]
JobN --> CheckN["run_job_command<br/>security checks"]
Check1 --> Policy1["security.can_act()"]
Check1 --> Policy2["security.is_rate_limited()"]
Check1 --> Policy3["security.is_command_allowed()"]
Check1 --> Policy4["security.is_path_allowed()"]
Check1 --> Policy5["security.record_action()"]
Sources: src/cron/scheduler.rs:21-45, src/cron/scheduler.rs:47-50, src/cron/scheduler.rs:87-101
Each layer enforces a different dimension of the security policy. Failures at any layer result in immediate rejection of the job without executing the command. Security policy violations are not retryable — if a job is blocked by policy, retries are skipped and the job is marked as failed.
Verifies that the current autonomy level permits write operations. Read-only mode blocks all scheduled shell commands.
// src/cron/scheduler.rs:397-401
if !security.can_act() {
return (
false,
"blocked by security policy: autonomy is read-only".to_string(),
);
}| Autonomy Level | Shell Jobs Allowed? | Agent Jobs Allowed? |
|---|---|---|
ReadOnly |
❌ Blocked | ✅ Allowed (read-only tools only) |
Supervised |
✅ Allowed | ✅ Allowed |
Full |
✅ Allowed | ✅ Allowed |
Sources: src/cron/scheduler.rs:397-401
Enforces the max_actions_per_hour budget. Once exhausted, all subsequent job executions are blocked until the rate limit window resets.
// src/cron/scheduler.rs:404-409
if security.is_rate_limited() {
return (
false,
"blocked by security policy: rate limit exceeded".to_string(),
);
}The rate limit counter is shared across:
- Interactive shell tool executions
- Scheduled cron jobs
- Tool executions triggered by agent jobs
- Git operations and other write tools
Sources: src/cron/scheduler.rs:404-409
Validates that the command's executable is present in the allowed_commands list. The check extracts the first token from the command string and matches it against the allowlist.
// src/cron/scheduler.rs:411-419
if !security.is_command_allowed(&job.command) {
return (
false,
format!(
"blocked by security policy: command not allowed: {}",
job.command
),
);
}Configuration Example:
[autonomy]
allowed_commands = ["sh", "git", "npm", "python3"]If allowed_commands is empty or not specified, all commands are permitted (deny-by-configuration approach requires explicit allowlist).
Sources: src/cron/scheduler.rs:411-419
Parses the shell command to extract path-like arguments and validates each against the security policy's forbidden path list and workspace scoping rules. This prevents scheduled jobs from accessing sensitive system directories even if the command itself is allowlisted.
The forbidden_path_argument function implements a multi-stage parser:
-
Normalization: Replace shell operators (
&&,||,;,|, newlines) with null bytes to segment commands - Tokenization: Split each segment by whitespace
-
Environment Variable Skipping: Skip leading
KEY=valuetokens - Executable Skipping: Skip the first non-environment token (the command itself)
- Path Detection: Identify arguments that look like filesystem paths
-
Validation: Check each path against
security.is_path_allowed()
flowchart LR
Input["Command String"] --> Normalize["Replace operators<br/>with \\0"]
Normalize --> Split["Split by \\0"]
Split --> Segment1["Segment 1"]
Split --> Segment2["Segment 2"]
Segment1 --> Tokenize1["Split whitespace"]
Tokenize1 --> SkipEnv1["Skip ENV=val tokens"]
SkipEnv1 --> SkipCmd1["Skip executable token"]
SkipCmd1 --> Args1["Extract arguments"]
Args1 --> Path1["Path-like?"]
Path1 -->|yes| Check1["is_path_allowed?"]
Path1 -->|no| Skip1["Skip"]
Check1 -->|blocked| ReturnForbidden["Return forbidden path"]
Check1 -->|allowed| Continue["Continue"]
Path Detection Heuristics:
A token is considered path-like if it:
- Starts with
/(absolute path) - Starts with
./(relative to current directory) - Starts with
../(parent directory) - Starts with
~/(home directory) - Contains
/anywhere (subdirectory reference)
And it is not:
- Starting with
-(command flag) - Containing
://(URL) - Wrapped in quotes (stripped before checking)
Sources: src/cron/scheduler.rs:319-375
Attempts to decrement the action budget counter. If the budget is already at zero, the command is blocked.
// src/cron/scheduler.rs:428-433
if !security.record_action() {
return (
false,
"blocked by security policy: action budget exhausted".to_string(),
);
}This check occurs after all validation layers to ensure that only approved commands consume budget quota.
Sources: src/cron/scheduler.rs:428-433
After passing all five security layers, the scheduler spawns the command as a subprocess with the following characteristics:
| Property | Value | Rationale |
|---|---|---|
| Shell | sh -lc |
Login shell with full environment |
| Working Directory | config.workspace_dir |
Scoped to agent workspace |
| Stdin | Stdio::null() |
No interactive input |
| Stdout/Stderr | Stdio::piped() |
Captured for logging |
| Timeout | 120 seconds (default) | Prevents runaway jobs |
| Kill on Drop | true |
Ensures cleanup if task is cancelled |
sequenceDiagram
participant Scheduler
participant Security as SecurityPolicy
participant Process as sh -lc
participant Workspace as workspace_dir
Scheduler->>Security: Pass 5 security layers
Security-->>Scheduler: Approved
Scheduler->>Process: spawn(sh -lc command)
Note over Process: stdin: null<br/>stdout: piped<br/>stderr: piped<br/>cwd: workspace_dir
Process->>Workspace: Execute in workspace
alt Completes within timeout
Process-->>Scheduler: stdout + stderr + exit code
Scheduler->>Scheduler: Format as output
else Timeout exceeded
Scheduler->>Process: Kill process
Scheduler->>Scheduler: Return timeout error
end
Sources: src/cron/scheduler.rs:435-467
The scheduler's retry logic distinguishes between transient failures (e.g., network errors, temporary file locks) and deterministic policy violations. Security policy blocks are never retried because they represent configuration mismatches that cannot be resolved by retrying the same operation.
// src/cron/scheduler.rs:72-75
if last_output.starts_with("blocked by security policy:") {
// Deterministic policy violations are not retryable.
return (false, last_output);
}| Error Message Prefix | Retryable? | Reason |
|---|---|---|
blocked by security policy: autonomy is read-only |
❌ No | Autonomy level is a stable configuration |
blocked by security policy: rate limit exceeded |
❌ No | Rate limit resets on a fixed schedule, not by retrying |
blocked by security policy: command not allowed |
❌ No | Allowlist is a fixed configuration |
blocked by security policy: forbidden path argument |
❌ No | Path validation rules are deterministic |
blocked by security policy: action budget exhausted |
❌ No | Budget exhaustion is a quota constraint, not a transient failure |
spawn error: ... |
✅ Yes | May be transient (e.g., process limit, temporary lock) |
job timed out after ... |
✅ Yes | Command may succeed on retry if system load decreases |
Sources: src/cron/scheduler.rs:52-85
Agent-type jobs bypass shell command validation but still enforce security controls through the agent's tool execution layer. When a scheduled agent job invokes tools like shell, file_write, or git_operations, each tool independently applies the same SecurityPolicy checks.
graph TB
AgentJob["Agent Job<br/>(CronJob::Agent)"] --> RunAgent["run_agent_job"]
RunAgent --> AgentRun["crate::agent::run"]
AgentRun --> ToolCall1["Tool: shell"]
AgentRun --> ToolCall2["Tool: file_write"]
AgentRun --> ToolCall3["Tool: git_operations"]
ToolCall1 --> ShellCheck["ShellTool::execute"]
ShellCheck --> SecurityCheck1["security.can_act()"]
ShellCheck --> SecurityCheck2["security.is_command_allowed()"]
ShellCheck --> SecurityCheck3["security.record_action()"]
ToolCall3 --> GitCheck["GitOperationsTool::execute"]
GitCheck --> GitSecurity1["security.can_act()"]
GitCheck --> GitSecurity2["security.record_action()"]
GitCheck --> GitSanitize["sanitize_git_args"]
Key Differences from Shell Jobs:
-
No Pre-Execution Blocking: Agent jobs are always executed (they don't call
can_act()at the scheduler level) - Read-Only Mode Allowed: Agent jobs can run in read-only mode, but any write tools they attempt to use will be blocked
- Tool-Level Enforcement: Each tool applies its own security checks (see src/tools/git_operations.rs:517-548 for an example)
Sources: src/cron/scheduler.rs:119-150, src/tools/git_operations.rs:517-548
The scheduler security implementation includes comprehensive test coverage across all validation layers:
| Test Case | Validates | Source |
|---|---|---|
run_job_command_blocks_disallowed_command |
Command allowlist enforcement | src/cron/scheduler.rs:554-565 |
run_job_command_blocks_forbidden_path_argument |
Path argument validation | src/cron/scheduler.rs:568-580 |
run_job_command_blocks_readonly_mode |
Autonomy level check | src/cron/scheduler.rs:583-594 |
run_job_command_blocks_rate_limited |
Rate limiting enforcement | src/cron/scheduler.rs:597-608 |
execute_job_with_retry_recovers_after_first_failure |
Retry logic for transient failures | src/cron/scheduler.rs:611-629 |
execute_job_with_retry_exhausts_attempts |
Retry exhaustion handling | src/cron/scheduler.rs:632-644 |
Path Validation Test Examples:
The test suite validates that the command parser correctly identifies path arguments in various contexts:
# Blocked: absolute path argument
cat /etc/passwd
# Blocked: relative path with slashes
python3 ./scripts/dangerous.py
# Allowed: flags are not paths
ls -la --color=auto
# Blocked: path after environment variable
ENV_VAR=value cat /etc/shadow
# Blocked: path in multi-command pipeline
echo ok && cat /etc/hostsSources: src/cron/scheduler.rs:513-747
The scheduler's security behavior is controlled by the [autonomy] section of config.toml:
[autonomy]
level = "supervised" # ReadOnly | Supervised | Full
max_actions_per_hour = 100 # 0 = unlimited
allowed_commands = ["sh", "git", "npm"] # empty = deny all shell commands
workspace_only = true # restrict file operations to workspace_dirAdditionally, the [scheduler] section controls retry behavior:
[scheduler]
max_concurrent = 5 # maximum parallel job executions
[reliability]
scheduler_retries = 3 # retry attempts for transient failures
provider_backoff_ms = 1000 # initial backoff for retriesFor complete configuration documentation, see Configuration File Reference.
Sources: src/cron/scheduler.rs:21-27, src/cron/scheduler.rs:52-60