Skip to content
Merged
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
61 changes: 61 additions & 0 deletions .github/workflows/build-push-docker.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 9 additions & 8 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
Expand Down
157 changes: 137 additions & 20 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3745,6 +3749,56 @@ pub fn default_nostr_relays() -> Vec<String> {
]
}

// ── 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<String>,
}

/// 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<ScopedRepo>,
/// Allowed HTTP domain patterns (exact or wildcard).
#[serde(default)]
pub allowed_http_domains: Vec<String>,
/// Allowed file path prefixes.
#[serde(default)]
pub allowed_file_paths: Vec<String>,
/// Commands subject to scope enforcement. Default: `["gh", "gcloud"]`.
#[serde(default = "default_scoped_commands")]
pub scoped_commands: Vec<String>,
}

fn default_scoped_commands() -> Vec<String> {
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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"]);
}
}
2 changes: 2 additions & 0 deletions src/onboard/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
query_classification: crate::config::QueryClassificationConfig::default(),
transcription: crate::config::TranscriptionConfig::default(),
tts: crate::config::TtsConfig::default(),
scope_policy: crate::config::ScopePolicyConfig::default(),
};

println!(
Expand Down Expand Up @@ -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?;
Expand Down
2 changes: 2 additions & 0 deletions src/security/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)]
Expand Down
Loading
Loading