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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/adaptive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ shared persistence:
cargo add nemo-relay-adaptive --features redis-backend
```

The response cache is opt-in. Its required `namespace` identifies one trusted
cache-sharing domain and must not be reused across mutually untrusted tenants
or upstream authorities. When tenants share a backend, include every
answer-affecting tenant or routing header in `header_allowlist`; Relay cannot
infer application-owned tenant identity.

For local source development:

```bash
Expand Down
15 changes: 14 additions & 1 deletion crates/adaptive/tests/unit/response_cache/key_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,7 @@ fn cache_error_policy_partitions_tool_keys() {

#[test]
fn header_allowlist_policy_partitions_keys_and_normalizes_case() {
let request = request(json!({
let mut request = request(json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"temperature": 0.0,
Expand All @@ -1117,6 +1117,19 @@ fn header_allowlist_policy_partitions_keys_and_normalizes_case() {
key_of("openai", &request, &duplicate_spelling),
"case-only and duplicate policy spellings are equivalent"
);

request
.headers
.insert("x-tenant".to_string(), json!("tenant-a"));
let tenant_a = key_of("openai", &request, &tenant_partitioned);
request
.headers
.insert("x-tenant".to_string(), json!("tenant-b"));
assert_ne!(
tenant_a,
key_of("openai", &request, &tenant_partitioned),
"different allowlisted tenant identities must not share entries"
);
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ the dynamic plugin references in the selected physical `plugins.toml`. Dynamic
plugins with a manifest-declared JSON Schema provide structured field controls.
Other dynamic plugins use a raw JSON object editor.

At runtime, dynamic plugins that do not have an explicit host-policy override
are classified as required and must carry a valid signature from a configured
trusted public key. Installation and inspection remain available for unsigned
plugins, but Relay refuses to activate them. Worker processes inherit only the
small environment allowlist needed for process startup and TLS. Native plugins
run in-process and must be treated as trusted host code after verification.

The canonical plugin file is `plugins.toml`; user config lives at
`~/.config/nemo-relay/plugins.toml` or
`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. Use
Expand Down
36 changes: 17 additions & 19 deletions crates/cli/src/agents/claude/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ use serde_json::{Value, json};

use crate::agents::shared::adapters::{
AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify,
permission_request,
};
use crate::events::{AgentKind, NormalizedEvent};
use crate::events::AgentKind;

/// Normalizes Claude Code hook payloads and returns the hook response Claude expects.
///
/// Claude Code uses permission-bearing tool hooks, so pre-tool events are explicitly allowed
/// instead of returning the generic `{ continue: true }` shape. All other hooks acknowledge with
/// `{ continue: true }` so the gateway remains observational and never blocks Claude's lifecycle
/// by default. Note: Claude's hook output schema rejects `null` for optional string fields like
/// `stopReason`; omit them entirely instead.
/// Claude Code uses permission-bearing tool hooks. Pre-tool events acknowledge guardrail success
/// without granting host permission; the later `PermissionRequest` receives the final decision.
/// Note: Claude's hook output schema rejects `null` for optional string fields like `stopReason`;
/// omit them entirely instead.
pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
let events = classify(
&payload,
Expand All @@ -40,17 +40,15 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
],
},
);
// Response shape is decided by the primary event (first in the vec); secondary events like
// `TurnEnded` are observability-only and don't influence the hook response Claude gets back.
let response = match events.first() {
Some(NormalizedEvent::ToolStarted(_)) => json!({
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}),
_ => json!({ "continue": true }),
};
AdapterOutcome { events, response }
let response = json!({ "continue": true });
AdapterOutcome {
events,
response,
permission: permission_request(
&payload,
headers,
AgentKind::ClaudeCode,
&CLAUDE_CODE_PAYLOAD_EXTRACTOR,
),
}
}
32 changes: 24 additions & 8 deletions crates/cli/src/agents/claude/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ pub(crate) fn prepare(
[
"--plugin-dir".into(),
"<temporary-claude-plugin-dir>".into(),
"--settings".into(),
"<temporary-claude-settings>".into(),
],
);
insert_before_argument_boundary(
&mut launch.argv,
launch.host_index,
["--settings".into(), "<temporary-claude-settings>".into()],
);
launch
.env
.push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string()));
Expand Down Expand Up @@ -80,12 +83,12 @@ pub(crate) fn prepare(
insert_after_host(
&mut launch.argv,
launch.host_index,
[
"--plugin-dir".into(),
root.display().to_string(),
"--settings".into(),
settings_path.display().to_string(),
],
["--plugin-dir".into(), root.display().to_string()],
);
insert_before_argument_boundary(
&mut launch.argv,
launch.host_index,
["--settings".into(), settings_path.display().to_string()],
);
launch
.env
Expand All @@ -94,6 +97,19 @@ pub(crate) fn prepare(
Ok(())
}

fn insert_before_argument_boundary(
argv: &mut Vec<String>,
host_index: usize,
values: impl IntoIterator<Item = String>,
) {
let boundary = argv
.iter()
.skip(host_index + 1)
.position(|argument| argument == "--")
.map_or(argv.len(), |offset| host_index + 1 + offset);
argv.splice(boundary..boundary, values);
}

fn replace_custom_header(existing: &str, replacement: &str) -> String {
let replacement_name = replacement
.split_once(':')
Expand Down
8 changes: 7 additions & 1 deletion crates/cli/src/agents/codex/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use axum::http::HeaderMap;
use serde_json::{Value, json};

use crate::agents::shared::adapters::{
AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify,
AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, permission_request,
};
use crate::events::AgentKind;

Expand All @@ -32,5 +32,11 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
AdapterOutcome {
events,
response: json!({}),
permission: permission_request(
&payload,
headers,
AgentKind::Codex,
&CODEX_PAYLOAD_EXTRACTOR,
),
}
}
53 changes: 53 additions & 0 deletions crates/cli/src/agents/shared/adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub(crate) struct AdapterOutcome {
pub(crate) events: Vec<NormalizedEvent>,
/// Hook response body returned to the invoking agent process.
pub(crate) response: Value,
/// Final permission request evaluated separately so its observable hook event stays unchanged.
pub(crate) permission: Option<Result<ToolEvent, String>>,
}

pub(super) struct ClassificationRules<'a> {
Expand Down Expand Up @@ -221,6 +223,57 @@ pub(crate) struct ToolPathSet {
status: &'static [&'static [&'static str]],
}

pub(super) fn permission_request(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
) -> Option<Result<ToolEvent, String>> {
let event_name = extractor.event_name(payload)?;
if normalize_name(&event_name) != "permissionrequest" {
return None;
}
let session_id = match extractor.session_id(payload, headers) {
Some(value) if !value.trim().is_empty() => value,
_ => {
return Some(Err(
"permission request is missing a session identifier".into()
));
}
};
let tool = extractor.tool_call(payload, headers, &event_name);
let tool_call_id = match tool.tool_call_id {
Some(value) if !value.trim().is_empty() => value,
None if kind == AgentKind::ClaudeCode => String::new(),
_ => {
return Some(Err(
"permission request is missing a tool-call identifier".into()
));
}
};
let tool_name = match tool.tool_name {
Some(value) if !value.trim().is_empty() => value,
_ => return Some(Err("permission request is missing a tool name".into())),
};
let arguments = match tool.arguments {
Some(value) => value,
None => return Some(Err("permission request is missing tool arguments".into())),
};
Some(Ok(ToolEvent {
session_id,
agent_kind: kind,
event_name: event_name.clone(),
tool_call_id,
tool_name,
subagent_id: tool.subagent_id,
arguments,
result: Value::Null,
status: tool.status,
payload: payload.clone(),
metadata: extractor.metadata(payload, headers, kind, &event_name),
}))
}

/// Whether an extractor accepts the Claude installed-mode session header.
#[derive(Clone, Copy)]
pub(crate) enum SessionHeaderPolicy {
Expand Down
20 changes: 20 additions & 0 deletions crates/cli/src/agents/shared/alignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ impl ProviderRequestExtractor for AnthropicCountTokensRequestExtractor {
pub(crate) struct SessionAlias {
pub(crate) parent_session_id: String,
pub(crate) subagent_id: String,
authenticated_owner: Option<String>,
// Metadata explains why this alias exists and is stamped on rewritten events. Phoenix traces
// then stay filterable/debuggable even after the event has been moved under its parent scope.
metadata: Value,
Expand All @@ -228,6 +229,7 @@ impl SessionAlias {
Self {
parent_session_id,
subagent_id,
authenticated_owner: None,
metadata,
}
}
Expand All @@ -237,6 +239,14 @@ impl SessionAlias {
pub(crate) fn metadata(&self) -> Value {
self.metadata.clone()
}

pub(crate) fn set_authenticated_owner(&mut self, owner: Option<String>) {
self.authenticated_owner = owner;
}

pub(crate) fn authenticated_owner(&self) -> Option<&str> {
self.authenticated_owner.as_deref()
}
}

#[derive(Debug, Clone)]
Expand All @@ -245,6 +255,7 @@ pub(crate) struct PendingSubagentStart {
// hook or gateway request, after this hook request has already returned.
pub(crate) event: SessionEvent,
context: SubagentSessionContext,
authenticated_owner: Option<String>,
}

impl PendingSubagentStart {
Expand All @@ -259,6 +270,14 @@ impl PendingSubagentStart {
pub(crate) fn alias_for_child_session(&self, child_session_id: String) -> SessionAlias {
alias_for_child_session(child_session_id, &self.context)
}

pub(crate) fn set_authenticated_owner(&mut self, owner: Option<String>) {
self.authenticated_owner = owner;
}

pub(crate) fn authenticated_owner(&self) -> Option<&str> {
self.authenticated_owner.as_deref()
}
}

// Owns all cross-session correlation state used by the session manager. Keeping aliases and
Expand Down Expand Up @@ -551,6 +570,7 @@ pub(crate) async fn pending_subagent_start(
PendingSubagentStart {
event: session_event.clone(),
context,
authenticated_owner: None,
},
))
}
Expand Down
29 changes: 29 additions & 0 deletions crates/cli/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,12 +475,14 @@ const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32;
const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0";
const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0";
const HOOK_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/hook-client/v1\0";
const TRANSPARENT_GATEWAY_DOMAIN: &[u8] = b"nemo-relay/transparent-gateway/v1\0";
const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] =
b"nemo-relay/python-environment-attestation/v1\0";

/// Private proof installed into supported coding-agent provider configuration.
pub(crate) const BOOTSTRAP_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-client-token";
pub(crate) const HOOK_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-hook-client";

/// Stable health-proof context shared by a transparent wrapper and plugin-owned MCP client.
pub(crate) fn transparent_gateway_fingerprint(gateway_url: &str) -> String {
Expand Down Expand Up @@ -558,6 +560,33 @@ impl BootstrapChallengeKey {
hmac::verify(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN, &tag).is_ok()
}

pub(crate) fn hook_client_token(&self, identity: &str) -> String {
let identity = digest::digest(&digest::SHA256, identity.as_bytes())
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let mut context = hmac::Context::with_key(&self.0);
context.update(HOOK_CLIENT_TOKEN_DOMAIN);
context.update(identity.as_bytes());
format!("{identity}.{}", encode_hmac_tag(context.sign()))
}

pub(crate) fn verify_hook_client_token(&self, token: &str) -> Option<String> {
let (identity, signature) = token.split_once('.')?;
if identity.len() != 64 || !identity.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let encoded = signature.strip_prefix("hmac-sha256:")?;
let tag = decode_fixed_hex::<32>(encoded)?;
let mut message = Vec::with_capacity(HOOK_CLIENT_TOKEN_DOMAIN.len() + identity.len());
message.extend_from_slice(HOOK_CLIENT_TOKEN_DOMAIN);
message.extend_from_slice(identity.as_bytes());
hmac::verify(&self.0, &message, &tag)
.is_ok()
.then(|| format!("hook-client:{identity}"))
}

#[cfg(test)]
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
Self(hmac::Key::new(hmac::HMAC_SHA256, bytes))
Expand Down
Loading
Loading