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
5 changes: 5 additions & 0 deletions crates/temper-mcp/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,11 @@ impl RuntimeContext {
entity_set_resolver: None,
binary_path: None,
api_key: api_key.as_deref(),
// Local stdio MCP server: the host process is the
// developer's own machine, so host ops (local file
// read, cargo build) act on the developer's checkout
// and are legitimately allowed.
allow_host_ops: true,
};
temper_sandbox::dispatch::dispatch_temper_method(
&ctx,
Expand Down
109 changes: 109 additions & 0 deletions crates/temper-sandbox/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ pub struct DispatchContext<'a> {
pub binary_path: Option<&'a std::path::Path>,
/// Optional API key for authentication.
pub api_key: Option<&'a str>,
/// Whether this dispatch context may perform host-process operations —
/// local filesystem reads and spawning `cargo` (`upload_wasm`,
/// `compile_wasm`). True only for a runner whose host process is the
/// developer's own machine (the local stdio MCP server). The
/// server-hosted REPL sets this false: its host process is the Temper
/// server, so host ops there read the server's filesystem and run code
/// as the server user — a host-compromise vector, not a developer op.
pub allow_host_ops: bool,
}

impl<'a> DispatchContext<'a> {
Expand Down Expand Up @@ -87,6 +95,16 @@ pub async fn dispatch_temper_method(
dispatch_governance(ctx, method, args).await
}
// --- WASM ---
// Host ops (local file read, `cargo build`) are rejected before
// touching the filesystem unless this context is host-trusted. The
// server-hosted REPL is not: running these in the server process is a
// host-compromise vector (arbitrary file read + RCE as the server user).
"upload_wasm" | "compile_wasm" if !ctx.allow_host_ops => Err(format!(
"temper.{method}() is not available in this context. Host operations \
(local file read, cargo build) run only on the developer's own \
machine via the local MCP server, never inside the Temper server \
process."
)),
"upload_wasm" | "compile_wasm" => dispatch_wasm(ctx, method, args).await,
// --- Evolution / Observe ---
"get_trajectories" | "get_insights" | "get_evolution_records" | "check_sentinel" => {
Expand Down Expand Up @@ -736,3 +754,94 @@ fn resolve_sdk_path(binary_path: Option<&std::path::Path>) -> Result<String, Str
.to_string(),
)
}

#[cfg(test)]
mod host_op_gate_tests {
use super::*;

/// Build a dispatch context with a given host-op capability. `base_url`
/// points at a port nothing listens on, so any accidental loopback call
/// fails fast rather than reaching a real server.
fn ctx(client: &reqwest::Client, allow_host_ops: bool) -> DispatchContext<'_> {
DispatchContext {
http: client,
base_url: "http://127.0.0.1:1",
tenant: "default",
agent_id: None,
agent_type: None,
session_id: None,
principal_id: None,
principal_kind: None,
agent_role: None,
entity_set_resolver: None,
binary_path: None,
api_key: None,
allow_host_ops,
}
}

fn str_args(values: &[&str]) -> Vec<MontyObject> {
values
.iter()
.map(|v| MontyObject::String((*v).to_string()))
.collect()
}

/// When the context is not host-trusted, `upload_wasm` is rejected before
/// the filesystem is touched. We pass a path that exists and is readable
/// (`/etc/hosts`): if the gate failed to fire, dispatch would read it and
/// then fail on the loopback POST — a different error. Getting the
/// "not available in this context" message proves the read never happened.
#[tokio::test]
async fn upload_wasm_rejected_without_host_ops() {
let client = reqwest::Client::new();
let args = str_args(&["mod", "/etc/hosts"]);
let err = dispatch_temper_method(&ctx(&client, false), "upload_wasm", &args, &[])
.await
.expect_err("upload_wasm must be rejected without host ops");
assert!(
err.contains("not available in this context"),
"expected host-op rejection, got: {err}"
);
assert!(
!err.contains("failed to read"),
"gate must fire before any filesystem read, got: {err}"
);
}

/// `compile_wasm` is gated the same way and must be rejected before it
/// spawns `rustup`/`cargo`.
#[tokio::test]
async fn compile_wasm_rejected_without_host_ops() {
let client = reqwest::Client::new();
let args = str_args(&["mod", "pub fn main() {}"]);
let err = dispatch_temper_method(&ctx(&client, false), "compile_wasm", &args, &[])
.await
.expect_err("compile_wasm must be rejected without host ops");
assert!(
err.contains("not available in this context"),
"expected host-op rejection, got: {err}"
);
}

/// With host ops allowed (the local stdio MCP context), the gate does not
/// fire: dispatch proceeds into `upload_wasm` and fails at the filesystem
/// read of a nonexistent path — proving the capability flag, not a hardcoded
/// block, is what governs the two host methods.
Comment on lines +815 to +830

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test positive-path proof is coupled to a raw error-message substring

upload_wasm_allowed_with_host_ops_reaches_filesystem asserts err.contains("failed to read") to prove the filesystem read was attempted (gate did not fire). If dispatch_wasm ever changes its IO error format (e.g., to "io error" or "cannot read"), this assertion will fail on a refactoring-only change and will need updating alongside the error message, creating a maintenance coupling that is easy to overlook.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-sandbox/src/dispatch.rs
Line: 815-830

Comment:
**Test positive-path proof is coupled to a raw error-message substring**

`upload_wasm_allowed_with_host_ops_reaches_filesystem` asserts `err.contains("failed to read")` to prove the filesystem read was attempted (gate did not fire). If `dispatch_wasm` ever changes its IO error format (e.g., to `"io error"` or `"cannot read"`), this assertion will fail on a refactoring-only change and will need updating alongside the error message, creating a maintenance coupling that is easy to overlook.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

#[tokio::test]
async fn upload_wasm_allowed_with_host_ops_reaches_filesystem() {
let client = reqwest::Client::new();
let args = str_args(&["mod", "/nonexistent/temper-arn166-does-not-exist"]);
let err = dispatch_temper_method(&ctx(&client, true), "upload_wasm", &args, &[])
.await
.expect_err("read of a nonexistent path must fail");
assert!(
err.contains("failed to read"),
"gate must allow the read attempt when host ops are permitted, got: {err}"
);
assert!(
!err.contains("not available in this context"),
"host-trusted context must not reject host ops, got: {err}"
);
}
}
4 changes: 4 additions & 0 deletions crates/temper-sandbox/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ pub async fn run_repl(config: &ReplConfig, code: &str) -> Result<String> {
entity_set_resolver: None,
binary_path: None,
api_key: None,
// Server-hosted REPL: the host process is the Temper
// server, so host ops (local file read, cargo build) are a
// host-compromise vector and are disallowed here.
allow_host_ops: false,
};
dispatch_temper_method(&ctx, &function_name, args, &kwargs).await
}
Expand Down
64 changes: 64 additions & 0 deletions crates/temper-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,70 @@ pub(crate) async fn require_policy_auth(
None
}

/// Authorize a REPL sandbox execution request against Cedar policies.
///
/// `POST /api/repl` runs agent-supplied code that can invoke every non-host
/// `temper.*` method, so executing the REPL at all is a privileged capability
/// and must be authorized — not open to any caller who can reach the port.
///
/// Returns `Some(response)` (403 + `AuthorizationDenied`, with the recorded
/// decision id) if denied, `None` if allowed. Admin principals bypass, matching
/// [`require_policy_auth`].
///
/// Enforcement strength is bounded by the identity source: until the principal
/// is a resolved credential rather than a self-asserted `x-temper-*` header
/// (ARN-167), this gate trusts the claimed principal kind. The host-op
/// isolation in `temper-sandbox` — not this gate — is the unconditional stop
/// for the arbitrary file-read / RCE the endpoint otherwise exposed.
Comment on lines +243 to +244

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Wrong ARN reference in doc comment

The doc comment cites ARN-167 as the pending fix for the Admin/principal bypass, but per the PR description, ARN-167 is the TemperPaw sibling and ARN-170 is the kernel-specific tracker ("kernel Class A: header-trusted admin + fail-open"). A developer looking up ARN-167 to understand the kernel residual will land on the TemperPaw issue, not the open kernel work.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/api/mod.rs
Line: 243-244

Comment:
**Wrong ARN reference in doc comment**

The doc comment cites ARN-167 as the pending fix for the Admin/principal bypass, but per the PR description, ARN-167 is the TemperPaw sibling and ARN-170 is the kernel-specific tracker ("kernel Class A: header-trusted admin + fail-open"). A developer looking up ARN-167 to understand the kernel residual will land on the TemperPaw issue, not the open kernel work.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

pub(crate) async fn require_repl_auth(
state: &ServerState,
headers: &HeaderMap,
tenant: &str,
) -> Option<axum::response::Response> {
let security_ctx = security_context_from_headers(headers, None, None, None);
if matches!(security_ctx.principal.kind, PrincipalKind::Admin) {
return None;
}
Comment on lines +250 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Admin bypass is fully header-spoofable — Layer A gate provides no real barrier until ARN-170 lands

security_context_from_headers reads PrincipalKind from the unverified X-Temper-Principal-Kind header. Any caller who can reach the port can self-assert X-Temper-Principal-Kind: admin and bypass the Cedar check entirely, reaching the REPL sandbox with access to all non-host temper.* methods. This is the system-wide header-trust issue tracked under ARN-170 (and ARN-167 for TemperPaw) and is consistent with how every other Cedar gate in the codebase (require_policy_auth, require_observe_auth) behaves — the PR description acknowledges this explicitly. Layer B (allow_host_ops: false) is the durable stop for the worst-case attack regardless, but Layer A as written is effectively a no-op against a motivated unauthenticated caller until the credential-resolution path lands.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/api/mod.rs
Line: 250-253

Comment:
**Admin bypass is fully header-spoofable — Layer A gate provides no real barrier until ARN-170 lands**

`security_context_from_headers` reads `PrincipalKind` from the unverified `X-Temper-Principal-Kind` header. Any caller who can reach the port can self-assert `X-Temper-Principal-Kind: admin` and bypass the Cedar check entirely, reaching the REPL sandbox with access to all non-host `temper.*` methods. This is the system-wide header-trust issue tracked under ARN-170 (and ARN-167 for TemperPaw) and is consistent with how every other Cedar gate in the codebase (`require_policy_auth`, `require_observe_auth`) behaves — the PR description acknowledges this explicitly. Layer B (`allow_host_ops: false`) is the durable stop for the worst-case attack regardless, but Layer A as written is effectively a no-op against a motivated unauthenticated caller until the credential-resolution path lands.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga rita-aga Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and this is the load-bearing point. This is the systemic Class A header-trust issue (ARN-170), not a repl-specific hole — fixing it locally in require_repl_auth would duplicate the edge fix that ARN-170 makes once, for every Cedar gate. Layer B (allow_host_ops: false) closes the RCE/file-read regardless. Merge dependency: this PR's Layer-A guarantee is only complete once ARN-170's auth-edge PR lands, so #342 should merge with or after it, not alone. Not downgrading severity — it's real until ARN-170 merges; it resolves there, not here. (Doc comment still cites ARN-167; correcting to ARN-170 in a follow-up commit.)

if let Err(denial) = state.authorize_with_context(
&security_ctx,
"execute_repl",
"Sandbox",
&std::collections::BTreeMap::new(),
tenant,
) {
Comment on lines +254 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 resource_attrs empty in Cedar call, {tenant} in denial record

The authorize_with_context call passes &std::collections::BTreeMap::new() while record_authz_denial records {"tenant": tenant}. This means any Cedar policy that inspects resource.tenant on a Sandbox resource (e.g., restricting REPL access to a specific tenant) would never match — Cedar sees an attribute-less resource. The PR description notes this as a known P2 with no security impact, and require_policy_auth has the same inconsistency, so this is at least consistent. Populating the Cedar call with the same attrs as the denial record would let future policies use resource.tenant.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-server/src/api/mod.rs
Line: 254-260

Comment:
**`resource_attrs` empty in Cedar call, `{tenant}` in denial record**

The `authorize_with_context` call passes `&std::collections::BTreeMap::new()` while `record_authz_denial` records `{"tenant": tenant}`. This means any Cedar policy that inspects `resource.tenant` on a `Sandbox` resource (e.g., restricting REPL access to a specific tenant) would never match — Cedar sees an attribute-less resource. The PR description notes this as a known P2 with no security impact, and `require_policy_auth` has the same inconsistency, so this is at least consistent. Populating the Cedar call with the same attrs as the denial record would let future policies use `resource.tenant`.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

let reason = denial.to_string();
let pd = record_authz_denial(
state,
DenialInput {
tenant,
security_ctx: &security_ctx,
agent_id_override: None,
action: "execute_repl",
resource_type: "Sandbox",
resource_id: tenant,
resource_attrs: serde_json::json!({"tenant": tenant}),
reason: &reason,
module_name: None,
from_status: None,
},
)
.await;
return Some(
(
StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({
"error": {
"code": "AuthorizationDenied",
"message": format!("{reason} Decision {}", pd.id),
}
})),
)
.into_response(),
);
}
None
}

/// Cedar policy-management gate as an axum extractor.
///
/// Runs [`require_policy_auth`] against the `{tenant}` path parameter before
Expand Down
20 changes: 16 additions & 4 deletions crates/temper-server/src/api/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ pub(crate) struct ReplRequest {
/// POST /api/repl — execute Python code in the Temper Monty sandbox.
///
/// The sandbox provides `temper.*` methods (create, action, submit_specs, etc.)
/// that loop back to this server via HTTP. Agent identity is extracted from
/// that loop back to this server over HTTP; each loopback call is subject to the
/// server's normal Cedar authorization. Agent identity is taken from
/// `X-Temper-Principal-Id` / `X-Temper-Principal-Kind` / `X-Temper-Agent-Role`
/// headers and forwarded on internal requests.
/// headers and forwarded on those internal requests.
///
/// Security: 180s timeout, 64MB memory, method allowlisting, no filesystem or
/// network access. External APIs go through `[[integration]]` in IOA specs.
/// Executing the REPL requires Cedar authorization (`execute_repl` on
/// `Sandbox`). Host operations — local filesystem reads and `cargo build`
/// (`upload_wasm`/`compile_wasm`) — are NOT available here: this dispatch
/// context is not host-trusted, so those methods are rejected before touching
/// the host. They run only on the developer's own machine via the local MCP
/// server. Resource bounds: 180s timeout, 64MB memory.
#[instrument(skip_all, fields(otel.name = "POST /api/repl"))]
pub(crate) async fn handle_repl(
State(state): State<ServerState>,
Expand Down Expand Up @@ -59,6 +64,13 @@ pub(crate) async fn handle_repl(
Err(e) => return e.into_response(),
};

// Executing the REPL is a privileged capability (arbitrary agent code that
// can drive every non-host temper.* method). Gate it behind Cedar before
// running anything.
if let Some(resp) = super::require_repl_auth(&state, &headers, &tenant).await {
return resp;
}

let agent_id = principal_id.clone();
let port = state.listen_port.get().copied().unwrap_or(4200);
let code = body.code;
Expand Down
128 changes: 128 additions & 0 deletions crates/temper-server/tests/repl_auth_gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! `/api/repl` authorization gate (ARN-166).
//!
//! `POST /api/repl` executes agent-supplied code that can drive every non-host
//! `temper.*` method, so executing it is a privileged capability gated behind
//! Cedar (`execute_repl` on `Sandbox`). These tests pin the gate against a real,
//! deny-by-default tenant policy set:
//!
//! - an agent with no `execute_repl` grant is denied (403) before any code runs;
//! - an admin bypasses the gate (matching `require_policy_auth`) and runs;
//! - an agent that IS granted `execute_repl` runs — proving the gate is a real
//! Cedar evaluation keyed on the action, not a blanket agent-deny.
//!
//! The route is mounted only under the `observe` feature — the same feature
//! production (`temper-cli`) builds with — so this file compiles to nothing
//! without it. Run with: `cargo test -p temper-server --test repl_auth_gate
//! --features observe`.
#![cfg(feature = "observe")]

use axum::body::Body;
use axum::http::{Request, StatusCode};
use temper_runtime::ActorSystem;
use temper_server::{ServerState, SpecRegistry, build_router};
use temper_spec::csdl::parse_csdl;
use tower::ServiceExt;

const CSDL_XML: &str = include_str!("../../../test-fixtures/specs/model.csdl.xml");
const ORDER_IOA: &str = include_str!("../../../test-fixtures/specs/order.ioa.toml");

/// Grants `execute_repl` to admins only — an agent is not covered, so Cedar
/// default-denies it. (Admins bypass the gate before Cedar anyway; the grant
/// keeps the policy realistic rather than empty.)
const ADMIN_ONLY_REPL: &str =
r#"permit(principal is Admin, action == Action::"execute_repl", resource);"#;

/// Grants `execute_repl` to agents — the authorized-agent surface.
const AGENT_REPL: &str =
r#"permit(principal is Agent, action == Action::"execute_repl", resource);"#;

/// Build server state whose `default` tenant has `policy` loaded, so the gate is
/// evaluated against real Cedar policies rather than the permissive fallback
/// that `from_registry` installs (which would allow everything and make the
/// deny assertion vacuous).
fn state_with_policy(policy: &str) -> ServerState {
let csdl = parse_csdl(CSDL_XML).expect("CSDL should parse");
let mut registry = SpecRegistry::new();
registry.register_tenant(
"default",
csdl,
CSDL_XML.to_string(),
&[("Order", ORDER_IOA)],
);
let state = ServerState::from_registry(ActorSystem::new("repl-auth-gate-test"), registry);
state
.authz
.reload_tenant_policies("default", policy)
.expect("tenant policy should load");
state
}

fn repl_request(principal_kind: &str, code: &str) -> Request<Body> {
Request::post("/api/repl")
.header("Content-Type", "application/json")
.header("X-Temper-Principal-Kind", principal_kind)
.header("X-Temper-Principal-Id", "arn166-test-principal")
.body(Body::from(serde_json::json!({ "code": code }).to_string()))
.unwrap()
}

/// An agent with no `execute_repl` grant is denied with 403 before any code
/// runs — the tenant policy grants the action to admins only, so Cedar
/// default-denies the agent.
#[tokio::test]
async fn repl_denied_for_unauthorized_agent() {
let app = build_router(state_with_policy(ADMIN_ONLY_REPL));
let response = app
.oneshot(repl_request("agent", "result = 1 + 1"))
.await
.unwrap();

assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"], "AuthorizationDenied");
}

/// Admin bypasses the gate (matching `require_policy_auth`) even against the
/// deny-by-default policy set, so the REPL still runs for an authorized surface
/// — the gate closes the hole without removing the capability. A clean run
/// returns 200 with `error: null`; a sandbox execution error would surface as a
/// non-null `error` at 200 (see `handle_repl`), so asserting both status and a
/// null `error` pins that the code actually executed rather than silently
/// erroring.
#[tokio::test]
async fn repl_allowed_for_admin() {
let app = build_router(state_with_policy(ADMIN_ONLY_REPL));
let response = app
.oneshot(repl_request("admin", "result = 1 + 1"))
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], serde_json::Value::Null);
}

/// An agent that IS granted `execute_repl` passes the gate and runs — proving
/// the gate is a real Cedar evaluation keyed on the action, not a hardcoded
/// agent-deny. Same success shape as the admin case: 200 with `error: null`.
#[tokio::test]
async fn repl_allowed_for_authorized_agent() {
let app = build_router(state_with_policy(AGENT_REPL));
let response = app
.oneshot(repl_request("agent", "result = 1 + 1"))
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], serde_json::Value::Null);
}
Loading
Loading