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
73 changes: 1 addition & 72 deletions crates/clawdstrike/src/async_guards/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::async_guards::types::{
AsyncGuard, AsyncGuardConfig, CircuitBreakerConfig, RateLimitConfig, RetryConfig,
};
use crate::error::{Error, Result};
use crate::placeholders::resolve_placeholders_in_json;
use crate::policy::{
AsyncCircuitBreakerPolicyConfig, AsyncExecutionMode, AsyncGuardPolicyConfig,
AsyncRateLimitPolicyConfig, AsyncRetryPolicyConfig, CustomGuardSpec, Policy, TimeoutBehavior,
Expand Down Expand Up @@ -137,75 +138,3 @@ fn retry_for_policy(cfg: &AsyncRetryPolicyConfig) -> RetryConfig {
multiplier: cfg.multiplier.unwrap_or(2.0).max(1.0),
}
}

fn env_var_for_placeholder(raw: &str) -> std::result::Result<String, String> {
if let Some(rest) = raw.strip_prefix("secrets.") {
if rest.is_empty() {
return Err("placeholder ${secrets.} is invalid".to_string());
}
return Ok(rest.to_string());
}

if raw.is_empty() {
return Err("placeholder ${} is invalid".to_string());
}

Ok(raw.to_string())
}

fn resolve_placeholders_in_string(input: &str) -> Result<String> {
let mut out = String::with_capacity(input.len());
let mut i = 0usize;

while let Some(start_rel) = input[i..].find("${") {
let start = i + start_rel;
let after = start + 2;

let Some(end_rel) = input[after..].find('}') else {
break;
};
let end = after + end_rel;

out.push_str(&input[i..start]);

let raw = &input[after..end];
let env_name = env_var_for_placeholder(raw)
.map_err(|msg| Error::ConfigError(format!("invalid placeholder: {msg}")))?;
let value = std::env::var(&env_name).map_err(|_| {
Error::ConfigError(format!("missing environment variable {}", env_name))
})?;
out.push_str(&value);

i = end + 1;
}

out.push_str(&input[i..]);
Ok(out)
}

fn resolve_placeholders_in_json_inner(value: serde_json::Value) -> Result<serde_json::Value> {
match value {
serde_json::Value::String(s) => Ok(serde_json::Value::String(
resolve_placeholders_in_string(&s)?,
)),
serde_json::Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(resolve_placeholders_in_json_inner(item)?);
}
Ok(serde_json::Value::Array(out))
}
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
out.insert(k, resolve_placeholders_in_json_inner(v)?);
}
Ok(serde_json::Value::Object(out))
}
other => Ok(other),
}
}

fn resolve_placeholders_in_json(value: serde_json::Value) -> Result<serde_json::Value> {
resolve_placeholders_in_json_inner(value)
}
94 changes: 93 additions & 1 deletion crates/clawdstrike/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,8 @@ fn build_custom_guards_from_policy(
)));
};

let guard = registry.build(&spec.id, spec.config.clone())?;
let config = crate::placeholders::resolve_placeholders_in_json(spec.config.clone())?;
let guard = registry.build(&spec.id, config)?;
out.push(guard);
}

Expand Down Expand Up @@ -891,6 +892,28 @@ mod tests {
}
}

struct ExpectTokenFactory;

impl crate::guards::CustomGuardFactory for ExpectTokenFactory {
fn id(&self) -> &str {
"acme.expect_token"
}

fn build(&self, config: serde_json::Value) -> Result<Box<dyn Guard>> {
let token = config
.get("token")
.and_then(|v| v.as_str())
.unwrap_or_default();
if token != "sekret" {
return Err(Error::ConfigError(format!(
"expected token 'sekret' but got {:?}",
token
)));
}
Ok(Box::new(AlwaysWarnGuard))
}
}

#[tokio::test]
async fn test_policy_custom_guards_run_after_builtins_when_registry_provided() {
let yaml = r#"
Expand Down Expand Up @@ -925,6 +948,75 @@ custom_guards:
);
}

#[tokio::test]
async fn test_policy_custom_guards_resolve_placeholders_in_config_before_build() {
let key = "HC_TEST_CUSTOM_GUARD_TOKEN";
let prev = std::env::var(key).ok();
std::env::set_var(key, "sekret");

let yaml = format!(
r#"
version: "1.1.0"
name: Custom
custom_guards:
- id: "acme.expect_token"
enabled: true
config:
token: "${{{}}}"
"#,
key
);
let policy = Policy::from_yaml(&yaml).unwrap();

let mut registry = CustomGuardRegistry::new();
registry.register(ExpectTokenFactory);

let engine = HushEngine::builder(policy)
.with_custom_guard_registry(registry)
.build()
.unwrap();

let context = GuardContext::new();
let report = engine
.check_action_report(&GuardAction::FileAccess("/app/src/main.rs"), &context)
.await
.unwrap();
assert!(report.overall.allowed);

match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}

#[test]
fn test_policy_custom_guards_missing_env_placeholder_fails_closed() {
let key = "HC_TEST_MISSING_CUSTOM_GUARD_ENV";
let prev = std::env::var(key).ok();
std::env::remove_var(key);

let yaml = format!(
r#"
version: "1.1.0"
name: Custom
custom_guards:
- id: "acme.expect_token"
enabled: true
config:
token: "${{{}}}"
"#,
key
);

let err = Policy::from_yaml(&yaml).unwrap_err();
assert!(err.to_string().contains(key));

match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}

#[tokio::test]
async fn test_policy_custom_guards_fail_closed_when_registry_missing() {
let yaml = r#"
Expand Down
1 change: 1 addition & 0 deletions crates/clawdstrike/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub mod instruction_hierarchy;
pub mod irm;
pub mod jailbreak;
pub mod output_sanitizer;
mod placeholders;
pub mod policy;
pub mod policy_bundle;
pub mod watermarking;
Expand Down
73 changes: 73 additions & 0 deletions crates/clawdstrike/src/placeholders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use crate::error::{Error, Result};

pub(crate) fn env_var_for_placeholder(raw: &str) -> std::result::Result<String, String> {
if let Some(rest) = raw.strip_prefix("secrets.") {
if rest.is_empty() {
return Err("placeholder ${secrets.} is invalid".to_string());
}
return Ok(rest.to_string());
}

if raw.is_empty() {
return Err("placeholder ${} is invalid".to_string());
}

Ok(raw.to_string())
}

pub(crate) fn resolve_placeholders_in_string(input: &str) -> Result<String> {
let mut out = String::with_capacity(input.len());
let mut i = 0usize;

while let Some(start_rel) = input[i..].find("${") {
let start = i + start_rel;
let after = start + 2;

let Some(end_rel) = input[after..].find('}') else {
break;
};
let end = after + end_rel;

out.push_str(&input[i..start]);

let raw = &input[after..end];
let env_name = env_var_for_placeholder(raw)
.map_err(|msg| Error::ConfigError(format!("invalid placeholder: {msg}")))?;
let value = std::env::var(&env_name).map_err(|_| {
Error::ConfigError(format!("missing environment variable {}", env_name))
})?;
out.push_str(&value);

i = end + 1;
}

out.push_str(&input[i..]);
Ok(out)
}

fn resolve_placeholders_in_json_inner(value: serde_json::Value) -> Result<serde_json::Value> {
match value {
serde_json::Value::String(s) => Ok(serde_json::Value::String(
resolve_placeholders_in_string(&s)?,
)),
serde_json::Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(resolve_placeholders_in_json_inner(item)?);
}
Ok(serde_json::Value::Array(out))
}
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (k, v) in map {
out.insert(k, resolve_placeholders_in_json_inner(v)?);
}
Ok(serde_json::Value::Object(out))
}
other => Ok(other),
}
}

pub(crate) fn resolve_placeholders_in_json(value: serde_json::Value) -> Result<serde_json::Value> {
resolve_placeholders_in_json_inner(value)
}
6 changes: 6 additions & 0 deletions crates/clawdstrike/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,12 @@ impl Policy {
"config must be a JSON object".to_string(),
));
}

validate_placeholders_in_json(
&mut errors,
&format!("custom_guards[{}].config", idx),
&cg.config,
);
}
}

Expand Down
60 changes: 60 additions & 0 deletions crates/hush-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! - `hush policy lint <policyRef>` - Lint a policy (warnings)
//! - `hush policy test <testYaml>` - Run policy tests from YAML
//! - `hush policy impact <old> <new> <eventsJsonlPath|->` - Compare decisions across policies
//! - `hush policy migrate <input> --to 1.1.0 [--output <path>|--in-place] [--from <ver>|--legacy-openclaw]` - Migrate a policy to a supported schema version
//! - `hush policy version <policyRef>` - Show policy schema version compatibility
//! - `hush run --policy <ref|file> -- <cmd> <args…>` - Best-effort process wrapper (proxy + audit log + receipt)
//! - `hush daemon start|stop|status|reload` - Daemon management
Expand All @@ -40,6 +41,7 @@ mod policy_diff;
mod policy_event;
mod policy_impact;
mod policy_lint;
mod policy_migrate;
mod policy_pac;
mod policy_rego;
mod policy_test;
Expand Down Expand Up @@ -395,6 +397,40 @@ enum PolicyCommands {
json: bool,
},

/// Migrate a policy to a supported schema version
Migrate {
/// Input policy YAML path (use - for stdin)
input: String,

/// Target schema version (default: 1.1.0)
#[arg(long, default_value = "1.1.0")]
to: String,

/// Source schema version (e.g., 1.0.0). If omitted, uses best-effort detection.
#[arg(long)]
from: Option<String>,

/// Treat input as legacy OpenClaw policy (clawdstrike-v1.0) and translate to canonical schema.
#[arg(long, conflicts_with = "from")]
legacy_openclaw: bool,

/// Output path for migrated YAML (default: stdout, unless --json is used).
#[arg(short, long, conflicts_with = "in_place")]
output: Option<String>,

/// Overwrite the input file in-place (refuses stdin)
#[arg(long, conflicts_with = "output")]
in_place: bool,

/// Emit machine-readable JSON.
#[arg(long)]
json: bool,

/// Validate and report, but do not write files.
#[arg(long)]
dry_run: bool,
},

/// Build/verify signed policy bundles
Bundle {
#[command(subcommand)]
Expand Down Expand Up @@ -1636,6 +1672,30 @@ async fn cmd_policy(
stderr,
)),

PolicyCommands::Migrate {
input,
to,
from,
legacy_openclaw,
output,
in_place,
json,
dry_run,
} => Ok(policy_migrate::cmd_policy_migrate(
policy_migrate::PolicyMigrateCommand {
input,
from,
to,
legacy_openclaw,
output,
in_place,
json,
dry_run,
},
stdout,
stderr,
)),

PolicyCommands::Bundle { command } => Ok(policy_bundle::cmd_policy_bundle(
command,
remote_extends,
Expand Down
Loading