Skip to content

fix(sandbox,server): isolate REPL host ops and gate /api/repl behind Cedar (ARN-166)#342

Open
rita-aga wants to merge 2 commits into
mainfrom
claude/arn-166-repl-host-ops
Open

fix(sandbox,server): isolate REPL host ops and gate /api/repl behind Cedar (ARN-166)#342
rita-aga wants to merge 2 commits into
mainfrom
claude/arn-166-repl-host-ops

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes ARN-166 (CRITICAL) under epic ARN-165. Draft for review — do not merge.

Problem

POST /api/repl executes agent-supplied Python that can call temper.* methods. Two of those ran in the host process, not the sandbox:

  • temper.upload_wasm(name, path)tokio::fs::read(path) on an attacker path → arbitrary host file read (retrievable via /api/wasm/modules/{name}).
  • temper.compile_wasm(name, src)cargo build on attacker Rust → RCE as the server user.

The route had no authorization (identity read from self-asserted x-temper-* headers), so it was fully open on a standalone kernel and reachable under TemperPaw via the header bypass. The handler's own doc claimed "no filesystem or network access" — false.

Fix (two independent layers — ADR-0155)

Layer B — host-op isolation (the durable, identity-independent fix). A new allow_host_ops: bool on DispatchContext. upload_wasm/compile_wasm are rejected before touching the filesystem or spawning a process unless the context is host-trusted. Only the local stdio MCP server (temper-mcp/src/runtime.rs, the developer's own machine) sets true; the server-hosted REPL (temper-sandbox/src/repl.rs) sets false. Enumeration: the only two production DispatchContext constructors are those two; no Default impl exists; the field is required, so any future constructor must choose explicitly. The server path cannot reach host ops.

Layer A — Cedar authorization on /api/repl. require_repl_auth gates execution via the same authorize_with_context path as policy management (execute_repl on Sandbox), recording a governance decision on denial. Deny → 403 before any code runs.

Verification

  • Live exploit repro against a locally-built temper serve: even as admin (bypasses layer A), POST /api/repl {await temper.upload_wasm('x','/etc/passwd')} → rejected, file never read, zero loopback POSTs; compile_wasm same. Agent/anonymous → 403 before code runs. Benign return 1+1{"result":2} (capability preserved).
  • Tests: temper-sandbox host_op_gate_tests (3, assert the fs read never happens / the flag governs) + temper-server repl_auth_gate (3, deny-by-default Cedar denial, admin bypass, authorized allow) — all pass. Full temper-server lib suite green (a one-off native SIGSEGV under parallel load did not reproduce — see ARN-197).

Residual

The Cedar gate's Admin/principal derivation is header-spoofable until ARN-170 (kernel Class A: header-trusted admin + fail-open) lands; ARN-167 is the TemperPaw sibling. The RCE/file-read is closed independently by layer B regardless. One follow-up P2 noted in review (empty resource_attrs to Cedar vs {tenant} on the denial record — no security impact).

🤖 Generated with Claude Code

Greptile Summary

This PR closes a critical security vulnerability (ARN-166) where POST /api/repl had no authorization and allowed agent-supplied Python to invoke temper.upload_wasm / temper.compile_wasm, resulting in arbitrary host file-read and RCE as the server user. The fix is two independent layers: a new allow_host_ops: bool field on DispatchContext that gates host-process operations unconditionally at the dispatch layer, and a Cedar authorization gate (execute_repl on Sandbox) added to the HTTP handler before any code runs.

  • Layer B (host-op isolation): upload_wasm and compile_wasm are rejected before touching the filesystem or spawning cargo unless allow_host_ops is true; only the local stdio MCP server (developer's machine) sets this true — the server-hosted REPL sets it false. The field is required on the struct (no Default impl), so any future DispatchContext constructor must choose explicitly.
  • Layer A (Cedar gate): require_repl_auth follows the same authorize_with_context + denial-recording pattern as the existing require_policy_auth; Admin principals bypass matching that function's behavior; the enforcement boundary (header-spoofable until ARN-170) is documented in the function's doc comment.
  • Three unit tests pin the host-op gate semantics and three integration tests cover deny-by-default, admin bypass, and authorized-agent allow for the Cedar gate.

Confidence Score: 4/5

The unconditional host-op gate in dispatch.rs is the durable core of the fix and is correctly placed before any filesystem or process access; the Cedar gate in api/mod.rs is consistent with existing auth patterns.

Both security layers look correct: allow_host_ops false on the server REPL path closes the file-read and RCE primitives before any IO, and require_repl_auth follows the same Cedar enforcement path as require_policy_auth. The two findings are documentation and test fragility nits that do not affect runtime behavior.

crates/temper-server/src/api/mod.rs for the ARN-167 vs ARN-170 doc comment reference; crates/temper-sandbox/src/dispatch.rs for the failed to read string assertion in the positive-path host-op test.

Important Files Changed

Filename Overview
crates/temper-sandbox/src/dispatch.rs Adds allow_host_ops: bool to DispatchContext and a guard arm that rejects upload_wasm/compile_wasm before any filesystem/process access when the flag is false; includes thorough unit tests pinning gate-fires-first semantics.
crates/temper-sandbox/src/repl.rs Sets allow_host_ops: false on the server-hosted REPL's DispatchContext; change is a one-liner in the right struct construction site.
crates/temper-mcp/src/runtime.rs Sets allow_host_ops: true for the local stdio MCP server context; the comment correctly explains why this is the only legitimate location for host ops.
crates/temper-server/src/api/mod.rs Adds require_repl_auth, a Cedar gate for POST /api/repl using the same authorize_with_context + denial-recording pattern as require_policy_auth; minor: doc comment cites ARN-167 (TemperPaw) where ARN-170 (kernel) is the open kernel tracker.
crates/temper-server/src/api/repl.rs Adds the require_repl_auth call before any code execution in handle_repl; correctly placed after tenant extraction and before spawn_blocking.
crates/temper-server/tests/repl_auth_gate.rs Three integration tests cover deny-by-default, admin bypass, and authorized-agent allow; tests are correct but the positive-path proof relies on a raw error-message substring.
docs/adrs/0155-repl-host-op-isolation-and-authz.md ADR documenting the two-layer fix, alternatives considered, DST compliance notes, and rollback policy; thorough and accurate relative to the code.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Caller
    participant H as handle_repl
    participant A as require_repl_auth (Cedar)
    participant S as run_repl (sandbox)
    participant D as dispatch_temper_method

    C->>H: POST /api/repl
    H->>H: extract_tenant()
    H->>A: require_repl_auth(state, headers, tenant)
    alt Admin principal (header-asserted, ARN-170 pending)
        A-->>H: None (bypass)
    else Cedar deny
        A-->>H: Some(403 AuthorizationDenied)
        H-->>C: 403
    else Cedar allow
        A-->>H: None
    end
    H->>S: spawn_blocking, run_repl(config, code)
    S->>D: "dispatch_temper_method(ctx allow_host_ops=false)"
    alt upload_wasm or compile_wasm
        D-->>S: Err not available in this context
    else other temper method
        D->>D: HTTP loopback (Cedar-gated)
        D-->>S: Ok(result)
    end
    S-->>H: result or error
    H-->>C: 200
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Caller
    participant H as handle_repl
    participant A as require_repl_auth (Cedar)
    participant S as run_repl (sandbox)
    participant D as dispatch_temper_method

    C->>H: POST /api/repl
    H->>H: extract_tenant()
    H->>A: require_repl_auth(state, headers, tenant)
    alt Admin principal (header-asserted, ARN-170 pending)
        A-->>H: None (bypass)
    else Cedar deny
        A-->>H: Some(403 AuthorizationDenied)
        H-->>C: 403
    else Cedar allow
        A-->>H: None
    end
    H->>S: spawn_blocking, run_repl(config, code)
    S->>D: "dispatch_temper_method(ctx allow_host_ops=false)"
    alt upload_wasm or compile_wasm
        D-->>S: Err not available in this context
    else other temper method
        D->>D: HTTP loopback (Cedar-gated)
        D-->>S: Ok(result)
    end
    S-->>H: result or error
    H-->>C: 200
Loading

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/temper-server/src/api/mod.rs:243-244
**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.

### Issue 2 of 2
crates/temper-sandbox/src/dispatch.rs:815-830
**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.

Reviews (2): Last reviewed commit: "style: rustfmt repl_auth_gate test (ARN-..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

rita-aga and others added 2 commits July 6, 2026 16:30
…Cedar (ARN-166)

POST /api/repl was unauthenticated and dispatched temper.upload_wasm /
temper.compile_wasm, which ran host-process operations inside the Temper server
process: tokio::fs::read on an attacker-chosen path (arbitrary host file read,
retrievable via the module store) and cargo build on attacker Rust (RCE as the
server user). The handler doc comment even claimed "no filesystem or network
access" - false.

Two independent layers (see docs/adrs/0155):

- Host-op isolation (closes the RCE/file-read unconditionally): add
  allow_host_ops to DispatchContext. dispatch_temper_method rejects
  upload_wasm/compile_wasm before touching the host unless the context is
  host-trusted. Only the local stdio MCP (the developer's own machine) sets it
  true; the server REPL sets it false. The struct has no Default/builder and the
  field is required, so every constructor must set it (compile-enforced).

- Authorization: /api/repl now requires Cedar (execute_repl on Sandbox) before
  running any code, recording a governance decision on denial like the other
  gated endpoints. Admin bypasses, matching require_policy_auth - header-spoofable
  until ARN-170, which the host-op isolation does not depend on.

Tests: temper-sandbox host_op_gate_tests (3 - gate fires before the fs read;
allowed-with-host-ops still reaches the fs, proving the flag governs); temper-server
repl_auth_gate (3 - deny unauthorized agent via a real deny-by-default policy,
admin bypass, authorized-agent allow; observe-gated since the route is
observe-only). Verified live: even as admin, awaited upload_wasm('/etc/passwd')
and compile_wasm are rejected; agent/anonymous get 403; benign REPL still runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1pdoLhHH7TQgGuiadQiy
Wrap register_tenant call to satisfy the pre-push rustfmt gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BM1pdoLhHH7TQgGuiadQiy
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 03:44
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment on lines +250 to +253
let security_ctx = security_context_from_headers(headers, None, None, None);
if matches!(security_ctx.principal.kind, PrincipalKind::Admin) {
return None;
}

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.)

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

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

Comment on lines +243 to +244
/// isolation in `temper-sandbox` — not this gate — is the unconditional stop
/// for the arbitrary file-read / RCE the endpoint otherwise exposed.

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

Comment on lines +815 to +830
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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant