Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Added a local-workspace `download` tool with SSRF-safe redirects, strict HTTP
range validation, adaptive bounded concurrency, retry and sequential fallback,
optional SHA-256 verification, and crash-safe atomic publication.

## [5.3.2] - 2026-07-16

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ object-only backend that cannot execute it.
| Code Intelligence | `code_symbols`, `code_navigation`, `code_diagnostics` | Saved-file semantic metadata and locations with bounded results; source retrieval and mutation stay in the existing file tools |
| Commands and source control | `bash`, `git` | Bounded output, cancellation, process-group termination on Unix, and typed Git operations |
| Web evidence | `web_search`, `web_fetch` | Ranked multi-engine search, normalized sources, SSRF protections, extraction, and bounded pages |
| Downloads | `download` | Workspace-confined binary downloads with strict range validation, bounded parallelism, retries, checksums, and atomic publication |
| Structured output | `generate_object` | Schema-constrained model generation with validation and repair |
| Composition | `batch`, `program` | Safe batch scheduling and sandboxed JavaScript programmatic tool calling |
| Delegation | `task`, `parallel_task` | Foreground/background workers, bounded parallelism, partial results, and task tracking |
Expand Down
2 changes: 2 additions & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
lsp-types = "=0.97.0"
url = "=2.5.8"
percent-encoding = "2.3"
# Full JSON Schema validation for structured model output. Remote/file
# resolvers stay disabled so an untrusted schema cannot trigger ambient I/O.
jsonschema = { version = "0.47", default-features = false }
Expand Down Expand Up @@ -79,6 +80,7 @@ regex = "1.10"

# SHA256 for security provider
sha256 = "1.5"
sha2 = "0.10"

# Shell argument parsing
shell-words = "1.1"
Expand Down
8 changes: 7 additions & 1 deletion core/src/hitl/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ fn test_session_lane() {
);
assert_eq!(SessionLane::from_tool_name("bash"), SessionLane::Execute);
assert_eq!(SessionLane::from_tool_name("write"), SessionLane::Execute);
assert_eq!(
SessionLane::from_tool_name("download"),
SessionLane::Execute
);
}

#[test]
Expand Down Expand Up @@ -54,7 +58,9 @@ fn test_session_lane_all_query() {

#[test]
fn test_session_lane_all_execute() {
let execute_tools = ["bash", "write", "edit", "delete", "move", "copy", "execute"];
let execute_tools = [
"bash", "write", "edit", "download", "delete", "move", "copy", "execute",
];
for tool in execute_tools {
assert_eq!(
SessionLane::from_tool_name(tool),
Expand Down
7 changes: 6 additions & 1 deletion core/src/permissions/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ fn invocation_crosses_local_symlink(
return shell_path_crosses_symlink(root, args);
}
let field = match tool.as_str() {
"read" | "write" | "edit" | "patch" => "file_path",
"read" | "write" | "edit" | "patch" | "download" => "file_path",
"grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
_ => return false,
};
Expand Down Expand Up @@ -226,6 +226,9 @@ pub(super) fn atomic_tool_is_bounded(tool_name: &str, args: &serde_json::Value)
// Workspace-confined edits and ordinary structured Git changes are the
// bounded operations that auto mode exists to streamline.
"write" | "edit" | "patch" => bounded_file_target(args),
// A missing destination is still bounded because download derives and
// sanitizes a workspace-relative filename from the response metadata.
"download" => args.get("file_path").is_none() || bounded_file_target(args),
"git" => {
classify_git(args) == PermissionDecision::Ask
&& git_call_is_known_bounded_mutation(args)
Expand Down Expand Up @@ -287,6 +290,8 @@ pub(super) fn classify_atomic_tool(
PermissionDecision::Allow
}
"write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
"download" if args.get("file_path").is_none() => PermissionDecision::Ask,
"download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
// Patch carries its target in a separate top-level field. A missing or
// boundary-crossing target must never be silently approved.
"patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
Expand Down
15 changes: 14 additions & 1 deletion core/src/permissions/interactive/assessment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,19 @@ fn routine_assessment(tool_name: &str) -> ToolRiskAssessment {

fn bounded_assessment(tool_name: &str) -> ToolRiskAssessment {
let tool_type = tool_risk_type(tool_name);
if tool_name.eq_ignore_ascii_case("download") {
return ToolRiskAssessment::new(
ToolRiskLevel::Bounded,
ToolRiskDimensions::new(
tool_type,
OperationTarget::Multiple,
ImpactScope::Workspace,
Reversibility::Easy,
EnvironmentSensitivity::Mixed,
),
[ToolRiskReason::BoundedWorkspaceMutation],
);
}
let (reversibility, reason) = if tool_type == ToolRiskType::VersionControl {
(
Reversibility::Conditional,
Expand Down Expand Up @@ -251,7 +264,7 @@ pub(super) fn tool_risk_type(tool_name: &str) -> ToolRiskType {
match tool_name.to_ascii_lowercase().as_str() {
"read" | "grep" | "glob" | "ls" | "code_symbols" | "code_navigation"
| "code_diagnostics" | "search_skills" | "generate_object" => ToolRiskType::ReadOnly,
"write" | "edit" | "patch" => ToolRiskType::WorkspaceMutation,
"write" | "edit" | "patch" | "download" => ToolRiskType::WorkspaceMutation,
"bash" => ToolRiskType::CommandExecution,
"git" => ToolRiskType::VersionControl,
"web_search" | "web_fetch" => ToolRiskType::NetworkRead,
Expand Down
2 changes: 1 addition & 1 deletion core/src/permissions/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ impl PermissionRule {
.unwrap_or("")
.to_string()
}
"read" | "write" | "edit" => {
"read" | "write" | "edit" | "download" => {
// For file operations, use the file_path field
args.get("file_path")
.and_then(|v| v.as_str())
Expand Down
49 changes: 49 additions & 0 deletions core/src/permissions/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,55 @@ fn interactive_guardrail_exposes_four_risk_levels_without_weakening_hitl() {
);
}

#[test]
fn download_is_a_bounded_mixed_workspace_mutation() {
let default = InteractiveToolGuardrail::default();
let assessment = default.assess(
"download",
&json!({
"url": "https://example.com/release.bin",
"file_path": "artifacts/release.bin"
}),
);
assert_eq!(assessment.level, ToolRiskLevel::Bounded);
assert_eq!(
assessment.dimensions.tool_type,
ToolRiskType::WorkspaceMutation
);
assert_eq!(
assessment.dimensions.operation_target,
OperationTarget::Multiple
);
assert_eq!(
assessment.dimensions.environment_sensitivity,
EnvironmentSensitivity::Mixed
);
assert_eq!(
default.check(
"download",
&json!({"url": "https://example.com/release.bin"})
),
PermissionDecision::Ask
);
assert_eq!(
InteractiveToolGuardrail::for_mode("auto").check(
"download",
&json!({"url": "https://example.com/release.bin"})
),
PermissionDecision::Allow
);
assert_eq!(
default.check(
"download",
&json!({
"url": "https://example.com/release.bin",
"file_path": "../outside.bin"
})
),
PermissionDecision::Deny
);
}

#[test]
fn interactive_guardrail_aggregates_the_highest_batch_risk() {
let guardrail = InteractiveToolGuardrail::for_mode("auto");
Expand Down
4 changes: 2 additions & 2 deletions core/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub enum SessionLane {
Control,
/// Query operations (P1) - workspace reads and Code Intelligence queries
Query,
/// Execute operations (P2) - bash, write, edit
/// Execute operations (P2) - bash, write, edit, download
Execute,
/// Generate operations (P3) - LLM calls
Generate,
Expand All @@ -59,7 +59,7 @@ impl SessionLane {
| "web_search" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
SessionLane::Query
}
"bash" | "write" | "edit" | "delete" | "move" | "copy" | "execute" => {
"bash" | "write" | "edit" | "download" | "delete" | "move" | "copy" | "execute" => {
SessionLane::Execute
}
_ => SessionLane::Execute,
Expand Down
10 changes: 9 additions & 1 deletion core/src/skills/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ impl Default for DefaultSkillValidator {
"write(*)".to_string(),
"edit(*)".to_string(),
"patch(*)".to_string(),
"download(*)".to_string(),
],
injection_patterns: vec![
"ignore previous".to_string(),
Expand Down Expand Up @@ -313,7 +314,14 @@ mod tests {
#[test]
fn test_dangerous_tool_patterns() {
let v = validator();
let dangerous = &["Bash(*)", "bash(*)", "write(*)", "edit(*)", "patch(*)"];
let dangerous = &[
"Bash(*)",
"bash(*)",
"write(*)",
"edit(*)",
"patch(*)",
"download(*)",
];
for pattern in dangerous {
let mut skill = make_skill("safe-skill", "content");
skill.allowed_tools = Some(pattern.to_string());
Expand Down
9 changes: 5 additions & 4 deletions core/src/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
fn explore_permissions() -> PermissionPolicy {
let mut policy = PermissionPolicy::new()
.allow_all(&["read", "grep", "glob", "ls", "web_fetch", "web_search"])
.deny_all(&["write", "edit", "task", "parallel_task"])
.deny_all(&["write", "edit", "download", "task", "parallel_task"])
.allow("Bash(ls:*)")
.allow("Bash(cat:*)")
.allow("Bash(head:*)")
Expand All @@ -826,6 +826,7 @@ fn general_permissions() -> PermissionPolicy {
"bash",
"web_fetch",
"web_search",
"download",
"git",
"patch",
"batch",
Expand All @@ -839,7 +840,7 @@ fn general_permissions() -> PermissionPolicy {
fn plan_permissions() -> PermissionPolicy {
let mut policy = PermissionPolicy::new()
.allow_all(&["read", "grep", "glob", "ls"])
.deny_all(&["write", "edit", "bash", "task", "parallel_task"]);
.deny_all(&["write", "edit", "download", "bash", "task", "parallel_task"]);
policy.default_decision = PermissionDecision::Deny;
policy
}
Expand All @@ -856,7 +857,7 @@ fn verification_permissions() -> PermissionPolicy {
"web_fetch",
"web_search",
])
.deny_all(&["write", "edit", "task", "parallel_task"]);
.deny_all(&["write", "edit", "download", "task", "parallel_task"]);
policy.default_decision = PermissionDecision::Deny;
policy
}
Expand All @@ -873,7 +874,7 @@ fn review_permissions() -> PermissionPolicy {
"web_fetch",
"web_search",
])
.deny_all(&["write", "edit", "task", "parallel_task"]);
.deny_all(&["write", "edit", "download", "task", "parallel_task"]);
policy.default_decision = PermissionDecision::Deny;
policy
}
Expand Down
23 changes: 23 additions & 0 deletions core/src/subagent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,20 @@ fn test_builtin_agent_permissions_are_bounded() {
.check("write", &serde_json::json!({"file_path": "x"})),
PermissionDecision::Deny
);
assert_eq!(
explore.permissions.check(
"download",
&serde_json::json!({"url": "https://example.com/file"})
),
PermissionDecision::Deny
);
assert_eq!(
general.permissions.check(
"download",
&serde_json::json!({"url": "https://example.com/file"})
),
PermissionDecision::Allow
);
assert_eq!(
general
.permissions
Expand Down Expand Up @@ -380,6 +394,15 @@ fn test_builtin_agent_permissions_are_bounded() {
"{} should stay read-only for workspace writes",
agent.name
);
assert_eq!(
agent.permissions.check(
"download",
&serde_json::json!({"url": "https://example.com/file"})
),
PermissionDecision::Deny,
"{} should stay read-only for downloads",
agent.name
);
assert_eq!(
agent
.permissions
Expand Down
Loading