Skip to content

Commit ea471f0

Browse files
authored
Merge pull request #936 from NVIDIA/release/0.8
Forward-merge release/0.8 into main
2 parents 7f2be55 + 97466cd commit ea471f0

45 files changed

Lines changed: 2721 additions & 252 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/adaptive/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ shared persistence:
6868
cargo add nemo-relay-adaptive --features redis-backend
6969
```
7070

71+
The response cache is opt-in. Its required `namespace` identifies one trusted
72+
cache-sharing domain and must not be reused across mutually untrusted tenants
73+
or upstream authorities. When tenants share a backend, include every
74+
answer-affecting tenant or routing header in `header_allowlist`; Relay cannot
75+
infer application-owned tenant identity.
76+
7177
For local source development:
7278

7379
```bash

crates/adaptive/tests/unit/response_cache/key_tests.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1099,7 +1099,7 @@ fn cache_error_policy_partitions_tool_keys() {
10991099

11001100
#[test]
11011101
fn header_allowlist_policy_partitions_keys_and_normalizes_case() {
1102-
let request = request(json!({
1102+
let mut request = request(json!({
11031103
"model": "gpt-4o",
11041104
"messages": [{"role": "user", "content": "hi"}],
11051105
"temperature": 0.0,
@@ -1124,6 +1124,19 @@ fn header_allowlist_policy_partitions_keys_and_normalizes_case() {
11241124
key_of("openai", &request, &duplicate_spelling),
11251125
"case-only and duplicate policy spellings are equivalent"
11261126
);
1127+
1128+
request
1129+
.headers
1130+
.insert("x-tenant".to_string(), json!("tenant-a"));
1131+
let tenant_a = key_of("openai", &request, &tenant_partitioned);
1132+
request
1133+
.headers
1134+
.insert("x-tenant".to_string(), json!("tenant-b"));
1135+
assert_ne!(
1136+
tenant_a,
1137+
key_of("openai", &request, &tenant_partitioned),
1138+
"different allowlisted tenant identities must not share entries"
1139+
);
11271140
}
11281141

11291142
#[test]

crates/cli/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ the dynamic plugin references in the selected physical `plugins.toml`. Dynamic
190190
plugins with a manifest-declared JSON Schema provide structured field controls.
191191
Other dynamic plugins use a raw JSON object editor.
192192

193+
At runtime, dynamic plugins that do not have an explicit host-policy override
194+
are classified as required and must carry a valid signature from a configured
195+
trusted public key. Installation and inspection remain available for unsigned
196+
plugins, but Relay refuses to activate them. Worker processes inherit only the
197+
small environment allowlist needed for process startup and TLS. Native plugins
198+
run in-process and must be treated as trusted host code after verification.
199+
193200
The canonical plugin file is `plugins.toml`; user config lives at
194201
`~/.config/nemo-relay/plugins.toml` or
195202
`$XDG_CONFIG_HOME/nemo-relay/plugins.toml`. Use

crates/cli/src/agents/claude/adapter.rs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ use serde_json::{Value, json};
66

77
use crate::agents::shared::adapters::{
88
AdapterOutcome, CLAUDE_CODE_PAYLOAD_EXTRACTOR, ClassificationRules, classify,
9+
permission_request,
910
};
10-
use crate::events::{AgentKind, NormalizedEvent};
11+
use crate::events::AgentKind;
1112

1213
/// Normalizes Claude Code hook payloads and returns the hook response Claude expects.
1314
///
14-
/// Claude Code uses permission-bearing tool hooks, so pre-tool events are explicitly allowed
15-
/// instead of returning the generic `{ continue: true }` shape. All other hooks acknowledge with
16-
/// `{ continue: true }` so the gateway remains observational and never blocks Claude's lifecycle
17-
/// by default. Note: Claude's hook output schema rejects `null` for optional string fields like
18-
/// `stopReason`; omit them entirely instead.
15+
/// Claude Code uses permission-bearing tool hooks. Pre-tool events acknowledge guardrail success
16+
/// without granting host permission; the later `PermissionRequest` receives the final decision.
17+
/// Note: Claude's hook output schema rejects `null` for optional string fields like `stopReason`;
18+
/// omit them entirely instead.
1919
pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
2020
let events = classify(
2121
&payload,
@@ -40,17 +40,15 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
4040
],
4141
},
4242
);
43-
// Response shape is decided by the primary event (first in the vec); secondary events like
44-
// `TurnEnded` are observability-only and don't influence the hook response Claude gets back.
45-
let response = match events.first() {
46-
Some(NormalizedEvent::ToolStarted(_)) => json!({
47-
"continue": true,
48-
"hookSpecificOutput": {
49-
"hookEventName": "PreToolUse",
50-
"permissionDecision": "allow"
51-
}
52-
}),
53-
_ => json!({ "continue": true }),
54-
};
55-
AdapterOutcome { events, response }
43+
let response = json!({ "continue": true });
44+
AdapterOutcome {
45+
events,
46+
response,
47+
permission: permission_request(
48+
&payload,
49+
headers,
50+
AgentKind::ClaudeCode,
51+
&CLAUDE_CODE_PAYLOAD_EXTRACTOR,
52+
),
53+
}
5654
}

crates/cli/src/agents/claude/launch.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,13 @@ pub(crate) fn prepare(
3636
[
3737
"--plugin-dir".into(),
3838
"<temporary-claude-plugin-dir>".into(),
39-
"--settings".into(),
40-
"<temporary-claude-settings>".into(),
4139
],
4240
);
41+
insert_before_argument_boundary(
42+
&mut launch.argv,
43+
launch.host_index,
44+
["--settings".into(), "<temporary-claude-settings>".into()],
45+
);
4346
launch
4447
.env
4548
.push(("ANTHROPIC_BASE_URL".into(), gateway_url.to_string()));
@@ -80,12 +83,12 @@ pub(crate) fn prepare(
8083
insert_after_host(
8184
&mut launch.argv,
8285
launch.host_index,
83-
[
84-
"--plugin-dir".into(),
85-
root.display().to_string(),
86-
"--settings".into(),
87-
settings_path.display().to_string(),
88-
],
86+
["--plugin-dir".into(), root.display().to_string()],
87+
);
88+
insert_before_argument_boundary(
89+
&mut launch.argv,
90+
launch.host_index,
91+
["--settings".into(), settings_path.display().to_string()],
8992
);
9093
launch
9194
.env
@@ -94,6 +97,19 @@ pub(crate) fn prepare(
9497
Ok(())
9598
}
9699

100+
fn insert_before_argument_boundary(
101+
argv: &mut Vec<String>,
102+
host_index: usize,
103+
values: impl IntoIterator<Item = String>,
104+
) {
105+
let boundary = argv
106+
.iter()
107+
.skip(host_index + 1)
108+
.position(|argument| argument == "--")
109+
.map_or(argv.len(), |offset| host_index + 1 + offset);
110+
argv.splice(boundary..boundary, values);
111+
}
112+
97113
fn replace_custom_header(existing: &str, replacement: &str) -> String {
98114
let replacement_name = replacement
99115
.split_once(':')

crates/cli/src/agents/codex/adapter.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use axum::http::HeaderMap;
55
use serde_json::{Value, json};
66

77
use crate::agents::shared::adapters::{
8-
AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify,
8+
AdapterOutcome, CODEX_PAYLOAD_EXTRACTOR, ClassificationRules, classify, permission_request,
99
};
1010
use crate::events::AgentKind;
1111

@@ -32,5 +32,11 @@ pub(crate) fn adapt(payload: Value, headers: &HeaderMap) -> AdapterOutcome {
3232
AdapterOutcome {
3333
events,
3434
response: json!({}),
35+
permission: permission_request(
36+
&payload,
37+
headers,
38+
AgentKind::Codex,
39+
&CODEX_PAYLOAD_EXTRACTOR,
40+
),
3541
}
3642
}

crates/cli/src/agents/shared/adapters.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pub(crate) struct AdapterOutcome {
2828
pub(crate) events: Vec<NormalizedEvent>,
2929
/// Hook response body returned to the invoking agent process.
3030
pub(crate) response: Value,
31+
/// Final permission request evaluated separately so its observable hook event stays unchanged.
32+
pub(crate) permission: Option<Result<ToolEvent, String>>,
3133
}
3234

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

226+
pub(super) fn permission_request(
227+
payload: &Value,
228+
headers: &HeaderMap,
229+
kind: AgentKind,
230+
extractor: &dyn AgentPayloadExtractor,
231+
) -> Option<Result<ToolEvent, String>> {
232+
let event_name = extractor.event_name(payload)?;
233+
if normalize_name(&event_name) != "permissionrequest" {
234+
return None;
235+
}
236+
let session_id = match extractor.session_id(payload, headers) {
237+
Some(value) if !value.trim().is_empty() => value,
238+
_ => {
239+
return Some(Err(
240+
"permission request is missing a session identifier".into()
241+
));
242+
}
243+
};
244+
let tool = extractor.tool_call(payload, headers, &event_name);
245+
let tool_call_id = match tool.tool_call_id {
246+
Some(value) if !value.trim().is_empty() => value,
247+
None if kind == AgentKind::ClaudeCode => String::new(),
248+
_ => {
249+
return Some(Err(
250+
"permission request is missing a tool-call identifier".into()
251+
));
252+
}
253+
};
254+
let tool_name = match tool.tool_name {
255+
Some(value) if !value.trim().is_empty() => value,
256+
_ => return Some(Err("permission request is missing a tool name".into())),
257+
};
258+
let arguments = match tool.arguments {
259+
Some(value) => value,
260+
None => return Some(Err("permission request is missing tool arguments".into())),
261+
};
262+
Some(Ok(ToolEvent {
263+
session_id,
264+
agent_kind: kind,
265+
event_name: event_name.clone(),
266+
tool_call_id,
267+
tool_name,
268+
subagent_id: tool.subagent_id,
269+
arguments,
270+
result: Value::Null,
271+
status: tool.status,
272+
payload: payload.clone(),
273+
metadata: extractor.metadata(payload, headers, kind, &event_name),
274+
}))
275+
}
276+
224277
/// Whether an extractor accepts the Claude installed-mode session header.
225278
#[derive(Clone, Copy)]
226279
pub(crate) enum SessionHeaderPolicy {

crates/cli/src/agents/shared/alignment.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ impl ProviderRequestExtractor for AnthropicCountTokensRequestExtractor {
216216
pub(crate) struct SessionAlias {
217217
pub(crate) parent_session_id: String,
218218
pub(crate) subagent_id: String,
219+
authenticated_owner: Option<String>,
219220
// Metadata explains why this alias exists and is stamped on rewritten events. Phoenix traces
220221
// then stay filterable/debuggable even after the event has been moved under its parent scope.
221222
metadata: Value,
@@ -228,6 +229,7 @@ impl SessionAlias {
228229
Self {
229230
parent_session_id,
230231
subagent_id,
232+
authenticated_owner: None,
231233
metadata,
232234
}
233235
}
@@ -237,6 +239,14 @@ impl SessionAlias {
237239
pub(crate) fn metadata(&self) -> Value {
238240
self.metadata.clone()
239241
}
242+
243+
pub(crate) fn set_authenticated_owner(&mut self, owner: Option<String>) {
244+
self.authenticated_owner = owner;
245+
}
246+
247+
pub(crate) fn authenticated_owner(&self) -> Option<&str> {
248+
self.authenticated_owner.as_deref()
249+
}
240250
}
241251

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

250261
impl PendingSubagentStart {
@@ -259,6 +270,14 @@ impl PendingSubagentStart {
259270
pub(crate) fn alias_for_child_session(&self, child_session_id: String) -> SessionAlias {
260271
alias_for_child_session(child_session_id, &self.context)
261272
}
273+
274+
pub(crate) fn set_authenticated_owner(&mut self, owner: Option<String>) {
275+
self.authenticated_owner = owner;
276+
}
277+
278+
pub(crate) fn authenticated_owner(&self) -> Option<&str> {
279+
self.authenticated_owner.as_deref()
280+
}
262281
}
263282

264283
// Owns all cross-session correlation state used by the session manager. Keeping aliases and
@@ -551,6 +570,7 @@ pub(crate) async fn pending_subagent_start(
551570
PendingSubagentStart {
552571
event: session_event.clone(),
553572
context,
573+
authenticated_owner: None,
554574
},
555575
))
556576
}

crates/cli/src/configuration/mod.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,12 +475,14 @@ const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32;
475475
const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
476476
const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0";
477477
const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0";
478+
const HOOK_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/hook-client/v1\0";
478479
const TRANSPARENT_GATEWAY_DOMAIN: &[u8] = b"nemo-relay/transparent-gateway/v1\0";
479480
const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] =
480481
b"nemo-relay/python-environment-attestation/v1\0";
481482

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

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

563+
pub(crate) fn hook_client_token(&self, identity: &str) -> String {
564+
let identity = digest::digest(&digest::SHA256, identity.as_bytes())
565+
.as_ref()
566+
.iter()
567+
.map(|byte| format!("{byte:02x}"))
568+
.collect::<String>();
569+
let mut context = hmac::Context::with_key(&self.0);
570+
context.update(HOOK_CLIENT_TOKEN_DOMAIN);
571+
context.update(identity.as_bytes());
572+
format!("{identity}.{}", encode_hmac_tag(context.sign()))
573+
}
574+
575+
pub(crate) fn verify_hook_client_token(&self, token: &str) -> Option<String> {
576+
let (identity, signature) = token.split_once('.')?;
577+
if identity.len() != 64 || !identity.bytes().all(|byte| byte.is_ascii_hexdigit()) {
578+
return None;
579+
}
580+
let encoded = signature.strip_prefix("hmac-sha256:")?;
581+
let tag = decode_fixed_hex::<32>(encoded)?;
582+
let mut message = Vec::with_capacity(HOOK_CLIENT_TOKEN_DOMAIN.len() + identity.len());
583+
message.extend_from_slice(HOOK_CLIENT_TOKEN_DOMAIN);
584+
message.extend_from_slice(identity.as_bytes());
585+
hmac::verify(&self.0, &message, &tag)
586+
.is_ok()
587+
.then(|| format!("hook-client:{identity}"))
588+
}
589+
561590
#[cfg(test)]
562591
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
563592
Self(hmac::Key::new(hmac::HMAC_SHA256, bytes))

0 commit comments

Comments
 (0)