Skip to content
Open
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
32 changes: 26 additions & 6 deletions crates/temper-authz/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,18 +225,38 @@ impl AuthzEngine {
// include tenant-defined custom attrs that can't be predicted by a
// static schema. Policy-level type checking suffices.

let entities = match Entity::new(principal_uid.clone(), principal_attrs, HashSet::new()) {
Ok(entity) => match Entities::from_entities([entity], None) {
Ok(e) => e,
let principal_entity =
match Entity::new(principal_uid.clone(), principal_attrs, HashSet::new()) {
Ok(entity) => entity,
Err(e) => {
return AuthzDecision::Deny(AuthzDenial::EngineError(format!(
"failed to build entity store: {e}"
"failed to build principal entity: {e}"
)));
}
},
};

// Build resource entity with attributes so Cedar can resolve
// `resource.Field` references (e.g. `resource.AgentId == principal.id`).
let mut resource_entity_attrs: HashMap<String, cedar_policy::RestrictedExpression> =
HashMap::new();
for (key, value) in resource_attrs {
insert_json_as_cedar(&mut resource_entity_attrs, key.clone(), value);
}
let resource_entity =
match Entity::new(resource_uid.clone(), resource_entity_attrs, HashSet::new()) {
Ok(entity) => entity,
Err(e) => {
return AuthzDecision::Deny(AuthzDenial::EngineError(format!(
"failed to build resource entity: {e}"
)));
}
};

let entities = match Entities::from_entities([principal_entity, resource_entity], None) {
Ok(e) => e,
Err(e) => {
return AuthzDecision::Deny(AuthzDenial::EngineError(format!(
"failed to build principal entity: {e}"
"failed to build entity store: {e}"
)));
}
};
Expand Down
8 changes: 1 addition & 7 deletions crates/temper-server/src/adapters/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async fn run_claude(
ctx: &AdapterContext,
resume: Option<&str>,
) -> Result<AdapterResult, AdapterError> {
let started = Instant::now();
let started = Instant::now(); // determinism-ok: wall-clock timing for adapter duration

let command_name = ctx
.integration_config
Expand All @@ -79,12 +79,6 @@ async fn run_claude(
command.arg("--add-dir").arg(skills_path);
}

if let Some(extra_args) = ctx.integration_config.get("args") {
for arg in extra_args.split_whitespace() {
command.arg(arg);
}
}

if let Some(workdir) = ctx.integration_config.get("workdir")
&& !workdir.trim().is_empty()
{
Expand Down
8 changes: 1 addition & 7 deletions crates/temper-server/src/adapters/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ impl AgentAdapter for CodexAdapter {
}

async fn execute(&self, ctx: AdapterContext) -> Result<AdapterResult, AdapterError> {
let started = Instant::now();
let started = Instant::now(); // determinism-ok: wall-clock timing for adapter duration

let command_name = ctx
.integration_config
Expand Down Expand Up @@ -62,12 +62,6 @@ impl AgentAdapter for CodexAdapter {
}
}

if let Some(extra_args) = ctx.integration_config.get("args") {
for arg in extra_args.split_whitespace() {
command.arg(arg);
}
}

let output = command.output().await.map_err(|e| {
AdapterError::Invocation(format!("failed to spawn '{command_name}': {e}"))
})?;
Expand Down
35 changes: 34 additions & 1 deletion crates/temper-server/src/adapters/http_webhook.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
//! Generic HTTP adapter.

use std::net::IpAddr; // determinism-ok: IP parsing for SSRF prevention, no network I/O
use std::time::Instant;

use async_trait::async_trait;

use super::{AdapterContext, AdapterError, AdapterResult, AgentAdapter};

/// Check whether a URL targets a private/loopback address (SSRF prevention).
fn is_private_url(url: &str) -> bool {
let domain = temper_wasm::authorized_host::extract_domain(url);
if let Ok(ip) = domain.parse::<IpAddr>() {
return ip.is_loopback() || is_private_ip(ip);
}
matches!(domain, "localhost" | "metadata.google.internal")
}

/// Returns true for RFC-1918, link-local, and cloud metadata IPs.
fn is_private_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback() // 127.0.0.0/8
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|| v4.is_link_local() // 169.254/16 (AWS metadata)
|| v4.octets()[0] == 0 // 0.0.0.0/8
}
IpAddr::V6(v6) => v6.is_loopback(), // ::1
}
}

/// Adapter implementation for generic HTTP callback execution.
#[derive(Debug, Default)]
pub struct HttpWebhookAdapter;
Expand All @@ -17,7 +40,7 @@ impl AgentAdapter for HttpWebhookAdapter {
}

async fn execute(&self, ctx: AdapterContext) -> Result<AdapterResult, AdapterError> {
let started = Instant::now();
let started = Instant::now(); // determinism-ok: wall-clock timing for adapter duration

let url = ctx
.integration_config
Expand All @@ -28,6 +51,16 @@ impl AgentAdapter for HttpWebhookAdapter {
AdapterError::Invocation("missing adapter config key 'url'".to_string())
})?;

let allow_private = ctx
.integration_config
.get("allow_private_urls")
.is_some_and(|v| v == "true");
if !allow_private && is_private_url(&url) {
return Err(AdapterError::Invocation(format!(
"SSRF blocked: adapter URL targets private/loopback address: {url}"
)));
}

let method = ctx
.integration_config
.get("method")
Expand Down
14 changes: 13 additions & 1 deletion crates/temper-server/src/adapters/openclaw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ impl AgentAdapter for OpenClawAdapter {
}

async fn execute(&self, ctx: AdapterContext) -> Result<AdapterResult, AdapterError> {
let started = Instant::now();
let started = Instant::now(); // determinism-ok: wall-clock timing for adapter duration

let gateway_url = ctx
.integration_config
Expand Down Expand Up @@ -82,6 +82,18 @@ impl AgentAdapter for OpenClawAdapter {
.await
.map_err(|e| AdapterError::Execution(format!("openclaw send failed: {e}")))?;

// Signal the gateway that we are waiting for the agent response.
let wait_msg = serde_json::json!({
"type": "agent.wait",
"id": request_id,
});
socket
.send(Message::Text(wait_msg.to_string().into()))
.await
.map_err(|e| {
AdapterError::Execution(format!("openclaw agent.wait send failed: {e}"))
})?;

let mut last_payload = serde_json::json!({});
let mut terminal_seen = false;

Expand Down
13 changes: 12 additions & 1 deletion crates/temper-server/src/state/dispatch/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,18 @@ impl crate::state::ServerState {
let secrets = self
.secrets_vault
.as_ref()
.map(|vault| vault.get_tenant_secrets(&tenant))
.map(|vault| {
let all = vault.get_tenant_secrets(&tenant);
// Only expose secrets referenced via {secret:KEY} in integration config.
let referenced: std::collections::BTreeMap<String, String> = all
.into_iter()
.filter(|(key, _)| {
let pattern = format!("{{secret:{key}}}");
integration.config.values().any(|v| v.contains(&pattern))
})
.collect();
referenced
})
.unwrap_or_default();

let adapter_ctx = AdapterContext {
Expand Down
3 changes: 3 additions & 0 deletions crates/temper-server/tests/adapter_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ on_success = "AdapterSucceeded"
on_failure = "AdapterFailed"
url = "{url}/execute"
method = "POST"
allow_private_urls = "true"
"#,
url = mock_server.uri()
);
Expand Down Expand Up @@ -163,6 +164,7 @@ on_success = "AdapterSucceeded"
on_failure = "AdapterFailed"
url = "{url}/execute"
method = "POST"
allow_private_urls = "true"
"#,
url = mock_server.uri()
);
Expand Down Expand Up @@ -251,6 +253,7 @@ on_success = "AdapterSucceeded"
on_failure = "AdapterFailed"
url = "{url}/execute"
method = "POST"
allow_private_urls = "true"
"#,
url = mock_server.uri()
);
Expand Down
Loading
Loading