diff --git a/.github/workflows/build-push-docker.yml b/.github/workflows/build-push-docker.yml new file mode 100644 index 00000000000..9078c874583 --- /dev/null +++ b/.github/workflows/build-push-docker.yml @@ -0,0 +1,61 @@ +name: Build and Push Docker Image + +permissions: + id-token: write + contents: read + +on: + push: + branches: + - master + paths-ignore: + - ".github/**" + - "docs/**" + - "*.md" + +env: + GAR_REPO: us-central1-docker.pkg.dev/mgmt-1729871488666/tooling + IMAGE_NAME: zeroclaw + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: OIDC Auth to GCP + uses: google-github-actions/auth@v2 + id: auth + with: + project_id: "adeptmind-ulta" + token_format: "access_token" + workload_identity_provider: ${{ secrets.PROVIDER_NAME }} + service_account: ${{ secrets.SA_EMAIL }} + + - name: Setup gcloud CLI + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker + run: gcloud auth configure-docker -q us-central1-docker.pkg.dev + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + target: release + push: false + load: true + tags: | + ${{ env.GAR_REPO }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ${{ env.GAR_REPO }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Push image to GAR + run: | + docker push ${{ env.GAR_REPO }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + docker push ${{ env.GAR_REPO }}/${{ env.IMAGE_NAME }}:latest diff --git a/src/config/mod.rs b/src/config/mod.rs index 9884b06f2e0..35970024857 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -7,17 +7,18 @@ pub use schema::{ build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config, AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig, - CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, - EstopConfig, FeishuConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, - HooksConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, - MemoryConfig, ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig, ObservabilityConfig, + CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig, + ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, + GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, + HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, + ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, - RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, - SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig, + RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, ScopePolicyConfig, ScopedRepo, + SecretsConfig, SecurityConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, + StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, TranscriptionConfig, TtsConfig, - EdgeTtsConfig, ElevenLabsTtsConfig, GoogleTtsConfig, OpenAiTtsConfig, TunnelConfig, - WebFetchConfig, WebSearchConfig, WebhookConfig, + TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, }; pub fn name_and_presence(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index c39008de5a5..aeb0afe38a2 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -102,6 +102,10 @@ pub struct Config { #[serde(default)] pub security: SecurityConfig, + /// Scope policy configuration (`[scope_policy]`). + #[serde(default)] + pub scope_policy: ScopePolicyConfig, + /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution. #[serde(default)] pub runtime: RuntimeConfig, @@ -3745,6 +3749,56 @@ pub fn default_nostr_relays() -> Vec { ] } +// ── Scope Policy ──────────────────────────────────────────────── + +/// Repository scope entry: org/name + allowed paths. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ScopedRepo { + /// Repository in "org/name" format. + pub repo: String, + /// Allowed path prefixes within the repository (must be non-empty). + pub paths: Vec, +} + +/// Scope policy configuration (`[scope_policy]` section). +/// +/// When enabled, restricts tool actions to explicitly allowed repos, +/// HTTP domains, file paths, and commands. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ScopePolicyConfig { + /// Enable scope policy enforcement. Default: `false`. + #[serde(default)] + pub enabled: bool, + /// Allowed repository scopes. + #[serde(default)] + pub allowed_repos: Vec, + /// Allowed HTTP domain patterns (exact or wildcard). + #[serde(default)] + pub allowed_http_domains: Vec, + /// Allowed file path prefixes. + #[serde(default)] + pub allowed_file_paths: Vec, + /// Commands subject to scope enforcement. Default: `["gh", "gcloud"]`. + #[serde(default = "default_scoped_commands")] + pub scoped_commands: Vec, +} + +fn default_scoped_commands() -> Vec { + vec!["gh".to_string(), "gcloud".to_string()] +} + +impl Default for ScopePolicyConfig { + fn default() -> Self { + Self { + enabled: false, + allowed_repos: Vec::new(), + allowed_http_domains: Vec::new(), + allowed_file_paths: Vec::new(), + scoped_commands: default_scoped_commands(), + } + } +} + // ── Config impl ────────────────────────────────────────────────── impl Default for Config { @@ -3765,6 +3819,7 @@ impl Default for Config { observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: SecurityConfig::default(), + scope_policy: ScopePolicyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), @@ -4263,11 +4318,7 @@ impl Config { // Decrypt TTS provider API keys if let Some(ref mut openai) = config.tts.openai { - decrypt_optional_secret( - &store, - &mut openai.api_key, - "config.tts.openai.api_key", - )?; + decrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?; } if let Some(ref mut elevenlabs) = config.tts.elevenlabs { decrypt_optional_secret( @@ -4277,11 +4328,7 @@ impl Config { )?; } if let Some(ref mut google) = config.tts.google { - decrypt_optional_secret( - &store, - &mut google.api_key, - "config.tts.google.api_key", - )?; + decrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?; } if let Some(ref mut ns) = config.channels_config.nostr { @@ -4576,6 +4623,13 @@ impl Config { // Proxy (delegate to existing validation) self.proxy.validate()?; + // Scope policy + for (i, repo) in self.scope_policy.allowed_repos.iter().enumerate() { + if repo.paths.is_empty() { + anyhow::bail!("scope_policy.allowed_repos[{i}].paths must not be empty"); + } + } + Ok(()) } @@ -4921,11 +4975,7 @@ impl Config { // Encrypt TTS provider API keys if let Some(ref mut openai) = config_to_save.tts.openai { - encrypt_optional_secret( - &store, - &mut openai.api_key, - "config.tts.openai.api_key", - )?; + encrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?; } if let Some(ref mut elevenlabs) = config_to_save.tts.elevenlabs { encrypt_optional_secret( @@ -4935,11 +4985,7 @@ impl Config { )?; } if let Some(ref mut google) = config_to_save.tts.google { - encrypt_optional_secret( - &store, - &mut google.api_key, - "config.tts.google.api_key", - )?; + encrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?; } if let Some(ref mut ns) = config_to_save.channels_config.nostr { @@ -5318,6 +5364,7 @@ default_temperature = 0.7 non_cli_excluded_tools: vec![], }, security: SecurityConfig::default(), + scope_policy: ScopePolicyConfig::default(), runtime: RuntimeConfig { kind: "docker".into(), ..RuntimeConfig::default() @@ -5540,6 +5587,7 @@ tool_dispatcher = "xml" observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: SecurityConfig::default(), + scope_policy: ScopePolicyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), @@ -7872,4 +7920,73 @@ require_otp_to_resume = true .expect_err("expected ttl validation failure"); assert!(err.to_string().contains("token_ttl_secs")); } + + // ── Scope Policy ──────────────────────────────────────────── + + #[test] + async fn scope_policy_config_default() { + let sp = ScopePolicyConfig::default(); + assert!(!sp.enabled); + assert!(sp.allowed_repos.is_empty()); + assert!(sp.allowed_http_domains.is_empty()); + assert!(sp.allowed_file_paths.is_empty()); + assert_eq!(sp.scoped_commands, vec!["gh", "gcloud"]); + } + + #[test] + async fn scope_policy_config_parses_valid_toml() { + let raw = r#" +enabled = true +allowed_http_domains = ["api.github.com", "*.googleapis.com"] +allowed_file_paths = ["/home/ci/workspace"] +scoped_commands = ["gh", "gcloud", "aws"] + +[[allowed_repos]] +repo = "zeroclaw-labs/zeroclaw" +paths = ["src/", "docs/"] + +[[allowed_repos]] +repo = "zeroclaw-labs/infra" +paths = ["terraform/"] +"#; + let parsed: ScopePolicyConfig = toml::from_str(raw).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.allowed_repos.len(), 2); + assert_eq!(parsed.allowed_repos[0].repo, "zeroclaw-labs/zeroclaw"); + assert_eq!(parsed.allowed_repos[0].paths, vec!["src/", "docs/"]); + assert_eq!(parsed.allowed_repos[1].repo, "zeroclaw-labs/infra"); + assert_eq!(parsed.allowed_repos[1].paths, vec!["terraform/"]); + assert_eq!( + parsed.allowed_http_domains, + vec!["api.github.com", "*.googleapis.com"] + ); + assert_eq!(parsed.allowed_file_paths, vec!["/home/ci/workspace"]); + assert_eq!(parsed.scoped_commands, vec!["gh", "gcloud", "aws"]); + } + + #[test] + async fn scope_policy_config_rejects_empty_paths() { + let mut cfg = Config::default(); + cfg.scope_policy.allowed_repos.push(ScopedRepo { + repo: "org/repo".into(), + paths: vec![], + }); + let err = cfg.validate().unwrap_err(); + assert!(err + .to_string() + .contains("scope_policy.allowed_repos[0].paths must not be empty")); + } + + #[test] + async fn scope_policy_config_defaults_when_section_missing() { + let toml_str = r#" +workspace_dir = "/tmp/workspace" +config_path = "/tmp/config.toml" +default_temperature = 0.7 +"#; + let parsed: Config = toml::from_str(toml_str).unwrap(); + assert!(!parsed.scope_policy.enabled); + assert!(parsed.scope_policy.allowed_repos.is_empty()); + assert_eq!(parsed.scope_policy.scoped_commands, vec!["gh", "gcloud"]); + } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index d294485fe86..8f9ddc5e9f8 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -170,6 +170,7 @@ pub async fn run_wizard(force: bool) -> Result { query_classification: crate::config::QueryClassificationConfig::default(), transcription: crate::config::TranscriptionConfig::default(), tts: crate::config::TtsConfig::default(), + scope_policy: crate::config::ScopePolicyConfig::default(), }; println!( @@ -522,6 +523,7 @@ async fn run_quick_setup_with_home( query_classification: crate::config::QueryClassificationConfig::default(), transcription: crate::config::TranscriptionConfig::default(), tts: crate::config::TtsConfig::default(), + scope_policy: crate::config::ScopePolicyConfig::default(), }; config.save().await?; diff --git a/src/security/mod.rs b/src/security/mod.rs index bbf8a7e5191..b230c4795f9 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -36,6 +36,7 @@ pub mod otp; pub mod pairing; pub mod policy; pub mod prompt_guard; +pub mod scope_policy; pub mod secrets; pub mod traits; @@ -51,6 +52,7 @@ pub use otp::OtpValidator; #[allow(unused_imports)] pub use pairing::PairingGuard; pub use policy::{AutonomyLevel, SecurityPolicy}; +pub use scope_policy::{ScopePolicy, ScopeViolation}; #[allow(unused_imports)] pub use secrets::SecretStore; #[allow(unused_imports)] diff --git a/src/security/scope_policy.rs b/src/security/scope_policy.rs new file mode 100644 index 00000000000..0a74033a8e7 --- /dev/null +++ b/src/security/scope_policy.rs @@ -0,0 +1,427 @@ +use crate::config::schema::ScopePolicyConfig; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Violation returned when an action exceeds the allowed scope. +#[derive(Debug, Clone)] +pub struct ScopeViolation { + pub tool: String, + pub target: String, + pub reason: String, +} + +impl std::fmt::Display for ScopeViolation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "scope violation on {}: {} ({})", + self.tool, self.reason, self.target + ) + } +} + +impl std::error::Error for ScopeViolation {} + +/// Runtime scope policy compiled from [`ScopePolicyConfig`]. +/// +/// When enabled, restricts tool actions to explicitly allowed repos, +/// HTTP domains, file paths, and commands. +#[derive(Debug, Clone)] +pub struct ScopePolicy { + enabled: bool, + /// org/repo (lowercase) -> allowed path prefixes + repos: HashMap>, + /// Allowed HTTP domains (lowercase) + http_domains: Vec, + /// Allowed file path prefixes + file_paths: Vec, + /// Commands subject to scope enforcement (lowercase) + scoped_commands: Vec, +} + +impl ScopePolicy { + /// Compile a [`ScopePolicyConfig`] into a runtime [`ScopePolicy`]. + pub fn from_config(config: ScopePolicyConfig) -> Self { + let mut repos = HashMap::new(); + for entry in config.allowed_repos { + repos.insert(entry.repo.to_lowercase(), entry.paths); + } + + Self { + enabled: config.enabled, + repos, + http_domains: config + .allowed_http_domains + .into_iter() + .map(|d| d.to_lowercase()) + .collect(), + file_paths: config + .allowed_file_paths + .into_iter() + .map(PathBuf::from) + .collect(), + scoped_commands: config + .scoped_commands + .into_iter() + .map(|c| c.to_lowercase()) + .collect(), + } + } + + /// Check an HTTP URL against scope policy. + /// + /// - Validates the domain against `http_domains` (if non-empty). + /// - Extracts org/repo from `api.github.com/repos/ORG/REPO/...` and checks against `repos`. + pub fn check_http_url(&self, url: &str) -> Result<(), ScopeViolation> { + if !self.enabled { + return Ok(()); + } + + let host = extract_host(url).unwrap_or_default().to_lowercase(); + + // Domain allowlist check + if !self.http_domains.is_empty() && !self.http_domains.contains(&host) { + return Err(ScopeViolation { + tool: "http_request".into(), + target: url.into(), + reason: format!("domain '{}' not in allowed list", host), + }); + } + + // GitHub API repo extraction + if host == "api.github.com" { + if let Some(org_repo) = extract_github_api_repo(url) { + self.check_repo("http_request", url, &org_repo)?; + } + } + + Ok(()) + } + + /// Check a shell command against scope policy. + /// + /// If the command's first word is in `scoped_commands`, extracts `--repo` + /// or `--project` flag values and checks them against `repos`. + /// Commands not in `scoped_commands` pass through. + pub fn check_shell_command(&self, command: &str) -> Result<(), ScopeViolation> { + if !self.enabled { + return Ok(()); + } + + let trimmed = command.trim(); + let first_word = trimmed.split_whitespace().next().unwrap_or_default(); + + if !self.scoped_commands.contains(&first_word.to_lowercase()) { + return Ok(()); + } + + // Extract --repo or --project values + let args: Vec<&str> = trimmed.split_whitespace().collect(); + for flag in &["--repo", "--project"] { + if let Some(value) = extract_flag_value(&args, flag) { + self.check_repo("shell", command, &value.to_lowercase())?; + } + } + + Ok(()) + } + + /// Check a git repository URL against scope policy. + /// + /// Supports HTTPS (`https://github.com/ORG/REPO.git`) and + /// SSH (`git@github.com:ORG/REPO.git`) formats. + pub fn check_git_repo(&self, url: &str) -> Result<(), ScopeViolation> { + if !self.enabled { + return Ok(()); + } + + if self.repos.is_empty() { + return Ok(()); + } + + let org_repo = extract_git_repo(url).ok_or_else(|| ScopeViolation { + tool: "git".into(), + target: url.into(), + reason: "could not extract org/repo from git URL".into(), + })?; + + self.check_repo("git", url, &org_repo) + } + + /// Check a file path against scope policy. + /// + /// If `file_paths` is empty, all paths are allowed. + pub fn check_file_path(&self, path: &str) -> Result<(), ScopeViolation> { + if !self.enabled { + return Ok(()); + } + + if self.file_paths.is_empty() { + return Ok(()); + } + + let target = PathBuf::from(path); + let allowed = self + .file_paths + .iter() + .any(|prefix| target.starts_with(prefix)); + + if !allowed { + return Err(ScopeViolation { + tool: "file".into(), + target: path.into(), + reason: "path not under any allowed prefix".into(), + }); + } + + Ok(()) + } + + /// Internal helper: check org/repo against allowed repos. + fn check_repo(&self, tool: &str, target: &str, org_repo: &str) -> Result<(), ScopeViolation> { + if self.repos.is_empty() { + return Ok(()); + } + + if !self.repos.contains_key(org_repo) { + return Err(ScopeViolation { + tool: tool.into(), + target: target.into(), + reason: format!("repo '{}' not in allowed list", org_repo), + }); + } + + Ok(()) + } +} + +// ── Helpers ──────────────────────────────────────────────────── + +/// Extract the host from a URL (e.g. `https://api.github.com/repos/...` → `api.github.com`). +fn extract_host(url: &str) -> Option<&str> { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + Some(after_scheme.split('/').next().unwrap_or(after_scheme)) +} + +/// Extract `org/repo` from a GitHub API URL like `https://api.github.com/repos/ORG/REPO/...`. +fn extract_github_api_repo(url: &str) -> Option { + let after_scheme = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let path = after_scheme.strip_prefix("api.github.com/")?; + let segments: Vec<&str> = path.split('/').collect(); + // Expected: ["repos", ORG, REPO, ...] + if segments.len() >= 3 && segments[0] == "repos" { + Some(format!("{}/{}", segments[1], segments[2]).to_lowercase()) + } else { + None + } +} + +/// Extract org/repo from a git URL (HTTPS or SSH). +fn extract_git_repo(url: &str) -> Option { + // HTTPS: https://github.com/ORG/REPO.git + if let Some(after_scheme) = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + { + let path = after_scheme.split('/').skip(1).collect::>(); + if path.len() >= 2 { + let org = path[0]; + let repo = path[1].strip_suffix(".git").unwrap_or(path[1]); + return Some(format!("{}/{}", org, repo).to_lowercase()); + } + } + + // SSH: git@github.com:ORG/REPO.git + if let Some(after_at) = url.strip_prefix("git@") { + let after_host = after_at.split(':').nth(1)?; + let parts: Vec<&str> = after_host.split('/').collect(); + if parts.len() >= 2 { + let org = parts[0]; + let repo = parts[1].strip_suffix(".git").unwrap_or(parts[1]); + return Some(format!("{}/{}", org, repo).to_lowercase()); + } + } + + None +} + +/// Extract the value of a `--flag=VALUE` or `--flag VALUE` from argument list. +fn extract_flag_value<'a>(args: &[&'a str], flag: &str) -> Option<&'a str> { + for (i, arg) in args.iter().enumerate() { + // --flag=VALUE + if let Some(val) = arg + .strip_prefix(flag) + .and_then(|rest| rest.strip_prefix('=')) + { + return Some(val); + } + // --flag VALUE + if *arg == flag { + return args.get(i + 1).copied(); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + fn make_policy(enabled: bool) -> ScopePolicy { + ScopePolicy::from_config(ScopePolicyConfig { + enabled, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + allowed_http_domains: vec!["api.github.com".into()], + allowed_file_paths: vec!["/home/user/project".into()], + scoped_commands: vec!["gh".into(), "gcloud".into()], + }) + } + + fn disabled_policy() -> ScopePolicy { + make_policy(false) + } + + fn enabled_policy() -> ScopePolicy { + make_policy(true) + } + + // ── disabled ──────────────────────────────────────────────── + + #[test] + fn disabled_allows_everything() { + let p = disabled_policy(); + assert!(p.check_http_url("https://evil.com/hack").is_ok()); + assert!(p.check_shell_command("gh --repo evil/repo pr list").is_ok()); + assert!(p.check_git_repo("git@github.com:evil/repo.git").is_ok()); + assert!(p.check_file_path("/etc/shadow").is_ok()); + } + + // ── check_http_url ────────────────────────────────────────── + + #[test] + fn check_http_url_allowed_domain() { + let p = enabled_policy(); + assert!(p + .check_http_url("https://api.github.com/repos/acme/zeroclaw/pulls") + .is_ok()); + } + + #[test] + fn check_http_url_denied_domain() { + let p = enabled_policy(); + assert!(p.check_http_url("https://evil.com/data").is_err()); + } + + #[test] + fn check_http_url_github_api_allowed_repo() { + let p = enabled_policy(); + assert!(p + .check_http_url("https://api.github.com/repos/acme/zeroclaw/issues") + .is_ok()); + } + + #[test] + fn check_http_url_github_api_denied_repo() { + let p = enabled_policy(); + let result = p.check_http_url("https://api.github.com/repos/other/repo/issues"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.reason.contains("other/repo")); + } + + // ── check_shell_command ───────────────────────────────────── + + #[test] + fn check_shell_command_unscoped_passes() { + let p = enabled_policy(); + assert!(p.check_shell_command("cargo build --release").is_ok()); + } + + #[test] + fn check_shell_command_scoped_allowed() { + let p = enabled_policy(); + assert!(p + .check_shell_command("gh --repo acme/zeroclaw pr list") + .is_ok()); + } + + #[test] + fn check_shell_command_scoped_allowed_equals() { + let p = enabled_policy(); + assert!(p + .check_shell_command("gh --repo=acme/zeroclaw pr list") + .is_ok()); + } + + #[test] + fn check_shell_command_scoped_denied() { + let p = enabled_policy(); + let result = p.check_shell_command("gh --repo other/repo pr list"); + assert!(result.is_err()); + } + + // ── check_git_repo ────────────────────────────────────────── + + #[test] + fn check_git_repo_https_allowed() { + let p = enabled_policy(); + assert!(p + .check_git_repo("https://github.com/acme/zeroclaw.git") + .is_ok()); + } + + #[test] + fn check_git_repo_ssh_allowed() { + let p = enabled_policy(); + assert!(p.check_git_repo("git@github.com:acme/zeroclaw.git").is_ok()); + } + + #[test] + fn check_git_repo_ssh_denied() { + let p = enabled_policy(); + let result = p.check_git_repo("git@github.com:evil/repo.git"); + assert!(result.is_err()); + } + + #[test] + fn check_git_repo_empty_repos_allows_all() { + let p = ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![], + ..Default::default() + }); + assert!(p.check_git_repo("git@github.com:any/repo.git").is_ok()); + } + + // ── check_file_path ───────────────────────────────────────── + + #[test] + fn check_file_path_allowed_prefix() { + let p = enabled_policy(); + assert!(p.check_file_path("/home/user/project/src/main.rs").is_ok()); + } + + #[test] + fn check_file_path_denied() { + let p = enabled_policy(); + assert!(p.check_file_path("/etc/passwd").is_err()); + } + + #[test] + fn check_file_path_empty_allows_all() { + let p = ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_file_paths: vec![], + ..Default::default() + }); + assert!(p.check_file_path("/anywhere/at/all").is_ok()); + } +} diff --git a/src/tools/file_edit.rs b/src/tools/file_edit.rs index 19c5f0cc67c..c721c2de614 100644 --- a/src/tools/file_edit.rs +++ b/src/tools/file_edit.rs @@ -1,5 +1,5 @@ use super::traits::{Tool, ToolResult}; -use crate::security::SecurityPolicy; +use crate::security::{ScopePolicy, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -12,11 +12,15 @@ use std::sync::Arc; /// the matched text. Security checks mirror [`super::file_write::FileWriteTool`]. pub struct FileEditTool { security: Arc, + scope_policy: Option>, } impl FileEditTool { - pub fn new(security: Arc) -> Self { - Self { security } + pub fn new(security: Arc, scope_policy: Option>) -> Self { + Self { + security, + scope_policy, + } } } @@ -76,7 +80,19 @@ impl Tool for FileEditTool { }); } - // ── 2. Autonomy check ────────────────────────────────────── + // ── 2. Scope policy check ──────────────────────────────────── + if let Some(ref sp) = self.scope_policy { + if let Err(violation) = sp.check_file_path(path) { + tracing::warn!(tool = "file_edit", target = %violation.target, reason = %violation.reason, "scope policy violation"); + return Ok(ToolResult { + success: false, + output: format!("Scope policy violation: {}", violation.reason), + error: Some(format!("Scope policy violation: {}", violation.reason)), + }); + } + } + + // ── 3. Autonomy check ────────────────────────────────────── if !self.security.can_act() { return Ok(ToolResult { success: false, @@ -250,13 +266,13 @@ mod tests { #[test] fn file_edit_name() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); assert_eq!(tool.name(), "file_edit"); } #[test] fn file_edit_schema_has_required_params() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); let schema = tool.parameters_schema(); assert!(schema["properties"]["path"].is_object()); assert!(schema["properties"]["old_string"].is_object()); @@ -276,7 +292,7 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test.txt", @@ -306,7 +322,7 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test.txt", @@ -337,7 +353,7 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test.txt", @@ -372,7 +388,7 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test.txt", @@ -398,7 +414,7 @@ mod tests { #[tokio::test] async fn file_edit_missing_path_param() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); let result = tool .execute(json!({"old_string": "a", "new_string": "b"})) .await; @@ -407,7 +423,7 @@ mod tests { #[tokio::test] async fn file_edit_missing_old_string_param() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); let result = tool .execute(json!({"path": "f.txt", "new_string": "b"})) .await; @@ -416,7 +432,7 @@ mod tests { #[tokio::test] async fn file_edit_missing_new_string_param() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); let result = tool .execute(json!({"path": "f.txt", "old_string": "a"})) .await; @@ -432,7 +448,7 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test.txt", @@ -463,7 +479,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "../../etc/passwd", @@ -481,7 +497,7 @@ mod tests { #[tokio::test] async fn file_edit_blocks_absolute_path() { - let tool = FileEditTool::new(test_security(std::env::temp_dir())); + let tool = FileEditTool::new(test_security(std::env::temp_dir()), None); let result = tool .execute(json!({ "path": "/etc/passwd", @@ -510,7 +526,7 @@ mod tests { symlink(&outside, workspace.join("escape_dir")).unwrap(); - let tool = FileEditTool::new(test_security(workspace.clone())); + let tool = FileEditTool::new(test_security(workspace.clone()), None); let result = tool .execute(json!({ "path": "escape_dir/target.txt", @@ -548,7 +564,7 @@ mod tests { .unwrap(); symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); - let tool = FileEditTool::new(test_security(workspace.clone())); + let tool = FileEditTool::new(test_security(workspace.clone()), None); let result = tool .execute(json!({ "path": "linked.txt", @@ -581,7 +597,10 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); + let tool = FileEditTool::new( + test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20), + None, + ); let result = tool .execute(json!({ "path": "test.txt", @@ -611,11 +630,10 @@ mod tests { .await .unwrap(); - let tool = FileEditTool::new(test_security_with( - dir.clone(), - AutonomyLevel::Supervised, - 0, - )); + let tool = FileEditTool::new( + test_security_with(dir.clone(), AutonomyLevel::Supervised, 0), + None, + ); let result = tool .execute(json!({ "path": "test.txt", @@ -646,7 +664,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "missing.txt", @@ -672,7 +690,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileEditTool::new(test_security(dir.clone())); + let tool = FileEditTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({ "path": "test\0evil.txt", @@ -686,4 +704,75 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + // ── Scope policy tests ────────────────────────────────────── + + fn scope_policy_allowing(paths: Vec<&str>) -> Arc { + use crate::config::schema::ScopePolicyConfig; + Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_file_paths: paths.into_iter().map(String::from).collect(), + ..Default::default() + })) + } + + #[tokio::test] + async fn file_edit_scope_policy_refuses_outside_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_fe_scope_deny"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let sp = scope_policy_allowing(vec!["/zeroclaw-data/workspace/"]); + let tool = FileEditTool::new(test_security(dir.clone()), Some(sp)); + let result = tool + .execute(json!({ + "path": "/etc/passwd", + "old_string": "root", + "new_string": "hacked" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_edit_scope_policy_allows_permitted_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_fe_scope_allow"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let sp = scope_policy_allowing(vec!["/zeroclaw-data/workspace/"]); + let tool = FileEditTool::new(test_security(dir.clone()), Some(sp)); + let result = tool + .execute(json!({ + "path": "/zeroclaw-data/workspace/test.txt", + "old_string": "hello", + "new_string": "goodbye" + })) + .await + .unwrap(); + + // Scope policy passes; may fail later on SecurityPolicy path check — that's fine. + assert!( + !result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation"), + "scope policy should not block an allowed path" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } } diff --git a/src/tools/file_write.rs b/src/tools/file_write.rs index 7ce604eb469..9adbbb620de 100644 --- a/src/tools/file_write.rs +++ b/src/tools/file_write.rs @@ -1,5 +1,5 @@ use super::traits::{Tool, ToolResult}; -use crate::security::SecurityPolicy; +use crate::security::{ScopePolicy, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -7,11 +7,15 @@ use std::sync::Arc; /// Write file contents with path sandboxing pub struct FileWriteTool { security: Arc, + scope_policy: Option>, } impl FileWriteTool { - pub fn new(security: Arc) -> Self { - Self { security } + pub fn new(security: Arc, scope_policy: Option>) -> Self { + Self { + security, + scope_policy, + } } } @@ -53,6 +57,18 @@ impl Tool for FileWriteTool { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + // ── Scope policy check ──────────────────────────────────── + if let Some(ref sp) = self.scope_policy { + if let Err(violation) = sp.check_file_path(path) { + tracing::warn!(tool = "file_write", target = %violation.target, reason = %violation.reason, "scope policy violation"); + return Ok(ToolResult { + success: false, + output: format!("Scope policy violation: {}", violation.reason), + error: Some(format!("Scope policy violation: {}", violation.reason)), + }); + } + } + if !self.security.can_act() { return Ok(ToolResult { success: false, @@ -189,13 +205,13 @@ mod tests { #[test] fn file_write_name() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let tool = FileWriteTool::new(test_security(std::env::temp_dir()), None); assert_eq!(tool.name(), "file_write"); } #[test] fn file_write_schema_has_path_and_content() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let tool = FileWriteTool::new(test_security(std::env::temp_dir()), None); let schema = tool.parameters_schema(); assert!(schema["properties"]["path"].is_object()); assert!(schema["properties"]["content"].is_object()); @@ -210,7 +226,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "out.txt", "content": "written!"})) .await @@ -232,7 +248,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"})) .await @@ -256,7 +272,7 @@ mod tests { .await .unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "exist.txt", "content": "new"})) .await @@ -277,7 +293,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "../../etc/evil", "content": "bad"})) .await @@ -290,7 +306,7 @@ mod tests { #[tokio::test] async fn file_write_blocks_absolute_path() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let tool = FileWriteTool::new(test_security(std::env::temp_dir()), None); let result = tool .execute(json!({"path": "/etc/evil", "content": "bad"})) .await @@ -301,14 +317,14 @@ mod tests { #[tokio::test] async fn file_write_missing_path_param() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let tool = FileWriteTool::new(test_security(std::env::temp_dir()), None); let result = tool.execute(json!({"content": "data"})).await; assert!(result.is_err()); } #[tokio::test] async fn file_write_missing_content_param() { - let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let tool = FileWriteTool::new(test_security(std::env::temp_dir()), None); let result = tool.execute(json!({"path": "file.txt"})).await; assert!(result.is_err()); } @@ -319,7 +335,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "empty.txt", "content": ""})) .await @@ -345,7 +361,7 @@ mod tests { symlink(&outside, workspace.join("escape_dir")).unwrap(); - let tool = FileWriteTool::new(test_security(workspace.clone())); + let tool = FileWriteTool::new(test_security(workspace.clone()), None); let result = tool .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"})) .await @@ -368,7 +384,10 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); + let tool = FileWriteTool::new( + test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20), + None, + ); let result = tool .execute(json!({"path": "out.txt", "content": "should-block"})) .await @@ -387,11 +406,10 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security_with( - dir.clone(), - AutonomyLevel::Supervised, - 0, - )); + let tool = FileWriteTool::new( + test_security_with(dir.clone(), AutonomyLevel::Supervised, 0), + None, + ); let result = tool .execute(json!({"path": "out.txt", "content": "should-block"})) .await @@ -429,7 +447,7 @@ mod tests { .unwrap(); symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); - let tool = FileWriteTool::new(test_security(workspace.clone())); + let tool = FileWriteTool::new(test_security(workspace.clone()), None); let result = tool .execute(json!({"path": "linked.txt", "content": "overwritten"})) .await @@ -456,7 +474,7 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = FileWriteTool::new(test_security(dir.clone())); + let tool = FileWriteTool::new(test_security(dir.clone()), None); let result = tool .execute(json!({"path": "file\u{0000}.txt", "content": "bad"})) .await @@ -465,4 +483,65 @@ mod tests { let _ = tokio::fs::remove_dir_all(&dir).await; } + + // ── Scope policy tests ────────────────────────────────────── + + fn scope_policy_allowing(paths: Vec<&str>) -> Arc { + use crate::config::schema::ScopePolicyConfig; + Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_file_paths: paths.into_iter().map(String::from).collect(), + ..Default::default() + })) + } + + #[tokio::test] + async fn file_write_scope_policy_refuses_outside_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_fw_scope_deny"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let sp = scope_policy_allowing(vec!["/zeroclaw-data/workspace/"]); + let tool = FileWriteTool::new(test_security(dir.clone()), Some(sp)); + let result = tool + .execute(json!({"path": "/etc/passwd", "content": "bad"})) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn file_write_scope_policy_allows_permitted_path() { + let dir = std::env::temp_dir().join("zeroclaw_test_fw_scope_allow"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let sp = scope_policy_allowing(vec!["/zeroclaw-data/workspace/"]); + let tool = FileWriteTool::new(test_security(dir.clone()), Some(sp)); + let result = tool + .execute(json!({"path": "/zeroclaw-data/workspace/out.txt", "content": "ok"})) + .await + .unwrap(); + + // Scope policy passes; may fail later on SecurityPolicy path check — that's fine. + // We only verify scope policy did NOT reject it. + assert!( + !result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation"), + "scope policy should not block an allowed path" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } } diff --git a/src/tools/git_operations.rs b/src/tools/git_operations.rs index 5b2e64e44e8..57541baf020 100644 --- a/src/tools/git_operations.rs +++ b/src/tools/git_operations.rs @@ -1,5 +1,5 @@ use super::traits::{Tool, ToolResult}; -use crate::security::{AutonomyLevel, SecurityPolicy}; +use crate::security::{AutonomyLevel, ScopePolicy, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -9,13 +9,19 @@ use std::sync::Arc; pub struct GitOperationsTool { security: Arc, workspace_dir: std::path::PathBuf, + scope_policy: Option>, } impl GitOperationsTool { - pub fn new(security: Arc, workspace_dir: std::path::PathBuf) -> Self { + pub fn new( + security: Arc, + workspace_dir: std::path::PathBuf, + scope_policy: Option>, + ) -> Self { Self { security, workspace_dir, + scope_policy, } } @@ -492,6 +498,29 @@ impl Tool for GitOperationsTool { } }; + // Scope policy: check git repo URL if provided + if let Some(ref sp) = self.scope_policy { + let url_or_repo = args + .get("url") + .or_else(|| args.get("repo")) + .and_then(|v| v.as_str()); + if let Some(url) = url_or_repo { + if let Err(violation) = sp.check_git_repo(url) { + tracing::warn!( + tool = "git_operations", + target = %violation.target, + reason = %violation.reason, + "scope policy violation" + ); + return Ok(ToolResult { + success: false, + output: format!("Scope policy violation: {}", violation.reason), + error: Some(format!("Scope policy violation: {}", violation.reason)), + }); + } + } + } + // Check if we're in a git repository if !self.workspace_dir.join(".git").exists() { // Try to find .git in parent directories @@ -577,7 +606,7 @@ mod tests { autonomy: AutonomyLevel::Supervised, ..SecurityPolicy::default() }); - GitOperationsTool::new(security, dir.to_path_buf()) + GitOperationsTool::new(security, dir.to_path_buf(), None) } #[test] @@ -704,7 +733,7 @@ mod tests { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() }); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf(), None); let result = tool .execute(json!({"operation": "commit", "message": "test"})) @@ -733,7 +762,7 @@ mod tests { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() }); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf(), None); let result = tool.execute(json!({"operation": "branch"})).await.unwrap(); // Branch listing must not be blocked by read-only autonomy @@ -751,7 +780,7 @@ mod tests { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() }); - let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf(), None); // This will fail because there's no git repo, but it shouldn't be blocked by autonomy let result = tool.execute(json!({"operation": "status"})).await.unwrap(); @@ -810,4 +839,110 @@ mod tests { assert_eq!(truncated.chars().count(), 2000); } + + #[tokio::test] + async fn scope_policy_blocks_out_of_scope_repo() { + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + let tmp = TempDir::new().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let policy = ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + ..Default::default() + }); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + let tool = + GitOperationsTool::new(security, tmp.path().to_path_buf(), Some(Arc::new(policy))); + + // Out-of-scope repo must be refused + let result = tool + .execute(json!({ + "operation": "status", + "url": "https://github.com/evil/repo.git" + })) + .await + .unwrap(); + + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation")); + } + + #[tokio::test] + async fn scope_policy_allows_in_scope_repo() { + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + let tmp = TempDir::new().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let policy = ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + ..Default::default() + }); + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + let tool = + GitOperationsTool::new(security, tmp.path().to_path_buf(), Some(Arc::new(policy))); + + // In-scope repo must be allowed (status will succeed on the init'd repo) + let result = tool + .execute(json!({ + "operation": "status", + "url": "https://github.com/acme/zeroclaw.git" + })) + .await + .unwrap(); + + assert!(result.success); + } + + #[tokio::test] + async fn scope_policy_none_allows_all() { + let tmp = TempDir::new().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let tool = test_tool(tmp.path()); + + // No scope policy — any URL is fine + let result = tool + .execute(json!({ + "operation": "status", + "url": "https://github.com/evil/repo.git" + })) + .await + .unwrap(); + + assert!(result.success); + } } diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 513ba554ba6..6491b14a07a 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -1,5 +1,5 @@ use super::traits::{Tool, ToolResult}; -use crate::security::SecurityPolicy; +use crate::security::{ScopePolicy, SecurityPolicy}; use async_trait::async_trait; use serde_json::json; use std::sync::Arc; @@ -12,6 +12,7 @@ pub struct HttpRequestTool { allowed_domains: Vec, max_response_size: usize, timeout_secs: u64, + scope_policy: Option>, } impl HttpRequestTool { @@ -20,12 +21,14 @@ impl HttpRequestTool { allowed_domains: Vec, max_response_size: usize, timeout_secs: u64, + scope_policy: Option>, ) -> Self { Self { security, allowed_domains: normalize_allowed_domains(allowed_domains), max_response_size, timeout_secs, + scope_policy, } } @@ -123,7 +126,8 @@ impl HttpRequestTool { let builder = reqwest::Client::builder() .timeout(Duration::from_secs(timeout_secs)) .connect_timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()); + .redirect(reqwest::redirect::Policy::none()) + .user_agent("zeroclaw"); let builder = crate::config::apply_runtime_proxy_to_builder(builder, "tool.http_request"); let client = builder.build()?; @@ -222,6 +226,23 @@ impl Tool for HttpRequestTool { }); } + // Scope policy check (before URL validation) + if let Some(ref sp) = self.scope_policy { + if let Err(violation) = sp.check_http_url(url) { + tracing::warn!( + tool = "http_request", + target = %violation.target, + reason = %violation.reason, + "scope policy violation" + ); + return Ok(ToolResult { + success: false, + output: format!("Scope policy violation: {}", violation.reason), + error: Some(format!("Scope policy violation: {}", violation.reason)), + }); + } + } + let url = match self.validate_url(url) { Ok(v) => v, Err(e) => { @@ -463,6 +484,8 @@ mod tests { allowed_domains.into_iter().map(String::from).collect(), 1_000_000, 30, + "ZeroClaw/test".into(), + None, ) } @@ -570,7 +593,14 @@ mod tests { #[test] fn validate_requires_allowlist() { let security = Arc::new(SecurityPolicy::default()); - let tool = HttpRequestTool::new(security, vec![], 1_000_000, 30); + let tool = HttpRequestTool::new( + security, + vec![], + 1_000_000, + 30, + "ZeroClaw/test".into(), + None, + ); let err = tool .validate_url("https://example.com") .unwrap_err() @@ -686,7 +716,14 @@ mod tests { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() }); - let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30); + let tool = HttpRequestTool::new( + security, + vec!["example.com".into()], + 1_000_000, + 30, + "ZeroClaw/test".into(), + None, + ); let result = tool .execute(json!({"url": "https://example.com"})) .await @@ -701,7 +738,14 @@ mod tests { max_actions_per_hour: 0, ..SecurityPolicy::default() }); - let tool = HttpRequestTool::new(security, vec!["example.com".into()], 1_000_000, 30); + let tool = HttpRequestTool::new( + security, + vec!["example.com".into()], + 1_000_000, + 30, + "ZeroClaw/test".into(), + None, + ); let result = tool .execute(json!({"url": "https://example.com"})) .await @@ -724,6 +768,8 @@ mod tests { vec!["example.com".into()], 10, 30, + "ZeroClaw/test".into(), + None, ); let text = "hello world this is long"; let truncated = tool.truncate_response(text); @@ -738,6 +784,8 @@ mod tests { vec!["example.com".into()], 0, // max_response_size = 0 means no limit 30, + "ZeroClaw/test".into(), + None, ); let text = "a".repeat(10_000_000); assert_eq!(tool.truncate_response(&text), text); @@ -750,6 +798,8 @@ mod tests { vec!["example.com".into()], 5, 30, + "ZeroClaw/test".into(), + None, ); let text = "hello world"; let truncated = tool.truncate_response(text); @@ -935,4 +985,103 @@ mod tests { .to_string(); assert!(err.contains("IPv6")); } + + // ── Scope policy enforcement ──────────────────────────────── + + #[tokio::test] + async fn execute_scope_policy_blocks_out_of_scope_url() { + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + let scope = Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + allowed_http_domains: vec!["api.github.com".into()], + allowed_file_paths: vec![], + scoped_commands: vec![], + })); + let tool = HttpRequestTool::new( + security, + vec!["evil.com".into(), "api.github.com".into()], + 1_000_000, + 30, + "ZeroClaw/test".into(), + Some(scope), + ); + + let result = tool + .execute(json!({"url": "https://evil.com/data"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap() + .contains("Scope policy violation")); + assert!(result.output.contains("Scope policy violation")); + } + + #[tokio::test] + async fn execute_scope_policy_allows_in_scope_url() { + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + let scope = Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + allowed_http_domains: vec!["api.github.com".into()], + allowed_file_paths: vec![], + scoped_commands: vec![], + })); + let tool = HttpRequestTool::new( + security, + vec!["api.github.com".into()], + 1_000_000, + 30, + "ZeroClaw/test".into(), + Some(scope), + ); + + // This will fail at the network level (no real server), but should NOT + // be blocked by scope policy. We check it passes the scope gate. + let result = tool + .execute(json!({"url": "https://api.github.com/repos/acme/zeroclaw/pulls"})) + .await + .unwrap(); + // If scope had blocked it, error would contain "Scope policy violation" + assert!(!result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation")); + } + + #[tokio::test] + async fn execute_scope_none_allows_any_url() { + // When scope_policy is None, no scope check is applied + let tool = test_tool(vec!["example.com"]); + // This will fail at the network level, but scope shouldn't block it + let result = tool + .execute(json!({"url": "https://example.com/test"})) + .await + .unwrap(); + assert!(!result + .error + .as_deref() + .unwrap_or("") + .contains("Scope policy violation")); + } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0164bdda4f9..0a0ce003823 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -49,6 +49,7 @@ pub mod proxy_config; pub mod pushover; pub mod schedule; pub mod schema; +pub mod scope_check; pub mod screenshot; pub mod shell; pub mod traits; @@ -89,6 +90,7 @@ pub use pushover::PushoverTool; pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; +pub use scope_check::ScopeCheckTool; pub use screenshot::ScreenshotTool; pub use shell::ShellTool; pub use traits::Tool; @@ -100,7 +102,7 @@ pub use web_search_tool::WebSearchTool; use crate::config::{Config, DelegateAgentConfig}; use crate::memory::Memory; use crate::runtime::{NativeRuntime, RuntimeAdapter}; -use crate::security::SecurityPolicy; +use crate::security::{ScopePolicy, SecurityPolicy}; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; @@ -150,10 +152,10 @@ pub fn default_tools_with_runtime( runtime: Arc, ) -> Vec> { vec![ - Box::new(ShellTool::new(security.clone(), runtime)), + Box::new(ShellTool::new(security.clone(), runtime, None)), Box::new(FileReadTool::new(security.clone())), - Box::new(FileWriteTool::new(security.clone())), - Box::new(FileEditTool::new(security.clone())), + Box::new(FileWriteTool::new(security.clone(), None)), + Box::new(FileEditTool::new(security.clone(), None)), Box::new(GlobSearchTool::new(security.clone())), Box::new(ContentSearchTool::new(security)), ] @@ -209,11 +211,19 @@ pub fn all_tools_with_runtime( fallback_api_key: Option<&str>, root_config: &crate::config::Config, ) -> Vec> { + let scope_policy = Some(Arc::new(ScopePolicy::from_config( + root_config.scope_policy.clone(), + ))); + let mut tool_arcs: Vec> = vec![ - Arc::new(ShellTool::new(security.clone(), runtime)), + Arc::new(ShellTool::new( + security.clone(), + runtime, + scope_policy.clone(), + )), Arc::new(FileReadTool::new(security.clone())), - Arc::new(FileWriteTool::new(security.clone())), - Arc::new(FileEditTool::new(security.clone())), + Arc::new(FileWriteTool::new(security.clone(), scope_policy.clone())), + Arc::new(FileEditTool::new(security.clone(), scope_policy.clone())), Arc::new(GlobSearchTool::new(security.clone())), Arc::new(ContentSearchTool::new(security.clone())), Arc::new(CronAddTool::new(config.clone(), security.clone())), @@ -234,6 +244,7 @@ pub fn all_tools_with_runtime( Arc::new(GitOperationsTool::new( security.clone(), workspace_dir.to_path_buf(), + scope_policy.clone(), )), Arc::new(PushoverTool::new( security.clone(), @@ -274,9 +285,13 @@ pub fn all_tools_with_runtime( http_config.allowed_domains.clone(), http_config.max_response_size, http_config.timeout_secs, + scope_policy.clone(), ))); } + // Scope check advisory tool (always available when scope policy exists) + tool_arcs.push(Arc::new(ScopeCheckTool::new(scope_policy.clone()))); + if web_fetch_config.enabled { tool_arcs.push(Arc::new(WebFetchTool::new( security.clone(), diff --git a/src/tools/scope_check.rs b/src/tools/scope_check.rs new file mode 100644 index 00000000000..9aa0d823ada --- /dev/null +++ b/src/tools/scope_check.rs @@ -0,0 +1,304 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::ScopePolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Advisory tool that lets the LLM pre-validate an action against [`ScopePolicy`] +/// before executing it. Returns a structured JSON verdict. +pub struct ScopeCheckTool { + scope_policy: Option>, +} + +impl ScopeCheckTool { + pub fn new(scope_policy: Option>) -> Self { + Self { scope_policy } + } +} + +#[async_trait] +impl Tool for ScopeCheckTool { + fn name(&self) -> &str { + "scope_check" + } + + fn description(&self) -> &str { + "Pre-validate an action against the scope policy before execution. \ + Returns whether the action would be allowed and the reason." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action_type": { + "type": "string", + "enum": ["http", "shell", "git", "file"], + "description": "The type of action to check" + }, + "target": { + "type": "string", + "description": "The target to check (URL, command, git URL, or file path)" + } + }, + "required": ["action_type", "target"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let policy = match &self.scope_policy { + Some(p) => p, + None => { + return Ok(ToolResult { + success: true, + output: json!({ + "allowed": true, + "reason": "scope policy not configured" + }) + .to_string(), + error: None, + }); + } + }; + + let action_type = args + .get("action_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action_type' parameter"))?; + + let target = args + .get("target") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'target' parameter"))?; + + let result = match action_type { + "http" => policy.check_http_url(target), + "shell" => policy.check_shell_command(target), + "git" => policy.check_git_repo(target), + "file" => policy.check_file_path(target), + other => { + return Ok(ToolResult { + success: true, + output: json!({ + "allowed": false, + "reason": format!("unknown action type: {other}") + }) + .to_string(), + error: None, + }); + } + }; + + let (allowed, reason) = match result { + Ok(()) => (true, "action is within scope".to_string()), + Err(violation) => (false, violation.reason), + }; + + Ok(ToolResult { + success: true, + output: json!({ "allowed": allowed, "reason": reason }).to_string(), + error: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + + fn enabled_policy() -> Option> { + Some(Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: vec![ScopedRepo { + repo: "acme/zeroclaw".into(), + paths: vec!["/".into()], + }], + allowed_http_domains: vec!["api.github.com".into()], + allowed_file_paths: vec!["/home/user/project".into()], + scoped_commands: vec!["gh".into()], + }))) + } + + fn disabled_policy() -> Option> { + Some(Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: false, + ..Default::default() + }))) + } + + #[test] + fn tool_name() { + let tool = ScopeCheckTool::new(None); + assert_eq!(tool.name(), "scope_check"); + } + + #[test] + fn tool_schema_has_required_fields() { + let tool = ScopeCheckTool::new(None); + let schema = tool.parameters_schema(); + assert_eq!(schema["properties"]["action_type"]["type"], "string"); + assert_eq!(schema["properties"]["target"]["type"], "string"); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("action_type"))); + assert!(required.contains(&json!("target"))); + } + + #[tokio::test] + async fn no_policy_returns_allowed() { + let tool = ScopeCheckTool::new(None); + let result = tool + .execute(json!({"action_type": "http", "target": "https://evil.com"})) + .await + .unwrap(); + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + assert_eq!(output["reason"], "scope policy not configured"); + } + + #[tokio::test] + async fn disabled_policy_allows_everything() { + let tool = ScopeCheckTool::new(disabled_policy()); + let result = tool + .execute(json!({"action_type": "http", "target": "https://evil.com"})) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + } + + #[tokio::test] + async fn http_allowed_domain() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "http", + "target": "https://api.github.com/repos/acme/zeroclaw/pulls" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + } + + #[tokio::test] + async fn http_denied_domain() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({"action_type": "http", "target": "https://evil.com/data"})) + .await + .unwrap(); + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], false); + assert!(output["reason"].as_str().unwrap().contains("evil.com")); + } + + #[tokio::test] + async fn shell_allowed_command() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "shell", + "target": "gh --repo acme/zeroclaw pr list" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + } + + #[tokio::test] + async fn shell_denied_repo() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "shell", + "target": "gh --repo other/repo pr list" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], false); + } + + #[tokio::test] + async fn git_allowed() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "git", + "target": "https://github.com/acme/zeroclaw.git" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + } + + #[tokio::test] + async fn git_denied() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "git", + "target": "git@github.com:evil/repo.git" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], false); + } + + #[tokio::test] + async fn file_allowed() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({ + "action_type": "file", + "target": "/home/user/project/src/main.rs" + })) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], true); + } + + #[tokio::test] + async fn file_denied() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({"action_type": "file", "target": "/etc/shadow"})) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], false); + } + + #[tokio::test] + async fn unknown_action_type() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool + .execute(json!({"action_type": "ftp", "target": "ftp://server"})) + .await + .unwrap(); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["allowed"], false); + assert!(output["reason"].as_str().unwrap().contains("unknown")); + } + + #[tokio::test] + async fn missing_action_type_is_error() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool.execute(json!({"target": "https://example.com"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn missing_target_is_error() { + let tool = ScopeCheckTool::new(enabled_policy()); + let result = tool.execute(json!({"action_type": "http"})).await; + assert!(result.is_err()); + } +} diff --git a/src/tools/shell.rs b/src/tools/shell.rs index b6244a94d5c..594d5835ab7 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -1,5 +1,6 @@ use super::traits::{Tool, ToolResult}; use crate::runtime::RuntimeAdapter; +use crate::security::ScopePolicy; use crate::security::SecurityPolicy; use async_trait::async_trait; use serde_json::json; @@ -21,11 +22,20 @@ const SAFE_ENV_VARS: &[&str] = &[ pub struct ShellTool { security: Arc, runtime: Arc, + scope_policy: Option>, } impl ShellTool { - pub fn new(security: Arc, runtime: Arc) -> Self { - Self { security, runtime } + pub fn new( + security: Arc, + runtime: Arc, + scope_policy: Option>, + ) -> Self { + Self { + security, + runtime, + scope_policy, + } } } @@ -95,6 +105,17 @@ impl Tool for ShellTool { .and_then(|v| v.as_bool()) .unwrap_or(false); + if let Some(ref sp) = self.scope_policy { + if let Err(violation) = sp.check_shell_command(command) { + tracing::warn!(tool = "shell", target = %violation.target, reason = %violation.reason, "scope policy violation"); + return Ok(ToolResult { + success: false, + output: format!("Scope policy violation: {}", violation.reason), + error: Some(format!("Scope policy violation: {}", violation.reason)), + }); + } + } + if self.security.is_rate_limited() { return Ok(ToolResult { success: false, @@ -226,19 +247,31 @@ mod tests { #[test] fn shell_tool_name() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); assert_eq!(tool.name(), "shell"); } #[test] fn shell_tool_description() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); assert!(!tool.description().is_empty()); } #[test] fn shell_tool_schema_has_command() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let schema = tool.parameters_schema(); assert!(schema["properties"]["command"].is_object()); assert!(schema["required"] @@ -250,7 +283,11 @@ mod tests { #[tokio::test] async fn shell_executes_allowed_command() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "echo hello"})) .await @@ -262,7 +299,11 @@ mod tests { #[tokio::test] async fn shell_blocks_disallowed_command() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "rm -rf /"})) .await @@ -274,7 +315,7 @@ mod tests { #[tokio::test] async fn shell_blocks_readonly() { - let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime()); + let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly), test_runtime(), None); let result = tool .execute(json!({"command": "ls"})) .await @@ -289,7 +330,11 @@ mod tests { #[tokio::test] async fn shell_missing_command_param() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool.execute(json!({})).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("command")); @@ -297,14 +342,22 @@ mod tests { #[tokio::test] async fn shell_wrong_type_param() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool.execute(json!({"command": 123})).await; assert!(result.is_err()); } #[tokio::test] async fn shell_captures_exit_code() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "ls /nonexistent_dir_xyz"})) .await @@ -314,7 +367,11 @@ mod tests { #[tokio::test] async fn shell_blocks_absolute_path_argument() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "cat /etc/passwd"})) .await @@ -329,7 +386,11 @@ mod tests { #[tokio::test] async fn shell_blocks_option_assignment_path_argument() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "grep --file=/etc/passwd root ./src"})) .await @@ -344,7 +405,11 @@ mod tests { #[tokio::test] async fn shell_blocks_short_option_attached_path_argument() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "grep -f/etc/passwd root ./src"})) .await @@ -359,7 +424,11 @@ mod tests { #[tokio::test] async fn shell_blocks_tilde_user_path_argument() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "cat ~root/.ssh/id_rsa"})) .await @@ -374,7 +443,11 @@ mod tests { #[tokio::test] async fn shell_blocks_input_redirection_path_bypass() { - let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime()); + let tool = ShellTool::new( + test_security(AutonomyLevel::Supervised), + test_runtime(), + None, + ); let result = tool .execute(json!({"command": "cat &2"})) .await @@ -649,7 +723,7 @@ mod tests { workspace_dir: std::env::temp_dir(), ..SecurityPolicy::default() }); - let tool = ShellTool::new(security, test_runtime()); + let tool = ShellTool::new(security, test_runtime(), None); let r1 = tool .execute(json!({"command": "echo first"})) @@ -667,4 +741,56 @@ mod tests { || r2.error.as_deref().unwrap_or("").contains("budget") ); } + + // ── §scope_policy shell enforcement tests ────────────────── + + fn test_scope_policy(repos: Vec<(&str, Vec<&str>)>, commands: Vec<&str>) -> Arc { + use crate::config::schema::{ScopePolicyConfig, ScopedRepo}; + Arc::new(ScopePolicy::from_config(ScopePolicyConfig { + enabled: true, + allowed_repos: repos + .into_iter() + .map(|(repo, paths)| ScopedRepo { + repo: repo.to_string(), + paths: paths.into_iter().map(|p| p.to_string()).collect(), + }) + .collect(), + scoped_commands: commands.into_iter().map(|c| c.to_string()).collect(), + ..ScopePolicyConfig::default() + })) + } + + #[tokio::test] + async fn shell_scope_policy_refuses_out_of_scope_repo() { + let sp = test_scope_policy(vec![("acme/zeroclaw", vec!["/"])], vec!["gh"]); + let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime(), Some(sp)); + let result = tool + .execute(json!({"command": "gh pr create --repo org/bad-repo"})) + .await + .expect("out-of-scope repo should return a result"); + assert!(!result.success); + assert!(result.output.contains("Scope policy violation")); + } + + #[tokio::test] + async fn shell_scope_policy_allows_unscoped_command() { + let sp = test_scope_policy(vec![("acme/zeroclaw", vec!["/"])], vec!["gh"]); + let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime(), Some(sp)); + let result = tool + .execute(json!({"command": "date"})) + .await + .expect("unscoped command should succeed"); + assert!(result.success); + } + + #[tokio::test] + async fn shell_scope_policy_none_passes_all() { + let tool = ShellTool::new(test_security(AutonomyLevel::Full), test_runtime(), None); + let result = tool + .execute(json!({"command": "echo no-scope"})) + .await + .expect("no scope policy should allow command"); + assert!(result.success); + assert!(result.output.contains("no-scope")); + } }