diff --git a/ci/loc-budget.yaml b/ci/loc-budget.yaml index b446bf8d4..ae1f5ce69 100644 --- a/ci/loc-budget.yaml +++ b/ci/loc-budget.yaml @@ -92,11 +92,11 @@ files: - path: inference-router/src/spawn/mod.rs baseline_2026_04_24: 1199 - phase0_cap: 1400 + phase0_cap: 1450 phase1_cap: 900 phase2_cap: 800 allow_grow: true - notes: "Path was inference-router/src/spawn.rs; converted to module after extracting docker dev-mode submodule (PR 43). Phase 0 cap bumped to 1400 in Hermes-support PR to absorb RuntimeKind env inheritance (sub-agent inherits parent runtime kind, explicit override path) + 3 serial-env unit tests with an EnvGuard helper. allow_grow honored at phase0 only; phase1+ enforces shrink." + notes: "Path was inference-router/src/spawn.rs; converted to module after extracting docker dev-mode submodule (PR 43). Phase 0 cap bumped to 1400 in Hermes-support PR to absorb RuntimeKind env inheritance (sub-agent inherits parent runtime kind, explicit override path) + 3 serial-env unit tests with an EnvGuard helper. Bumped to 1450 for sub-agent MCP inheritance: the spawn path reads the parent CR's governance.mcpServerRefs (parent_mcp_server_refs helper, singular-shim aware) and overlays them onto the child so sub-agents inherit MCP tool access; tests live in the sibling mcp_inherit_test.rs submodule to limit growth. allow_grow honored at phase0 only; phase1+ enforces shrink." - path: cli/src/commands/handoff.ts baseline_2026_04_24: 1119 diff --git a/cli/package-lock.json b/cli/package-lock.json index 698fd19c1..9f5580210 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kars-runtime/cli", - "version": "0.1.24", + "version": "0.1.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kars-runtime/cli", - "version": "0.1.24", + "version": "0.1.25", "license": "MIT", "dependencies": { "@azure/identity": "^4.0.0", diff --git a/cli/package.json b/cli/package.json index 1d1b8c323..62de99ce1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kars-runtime/cli", - "version": "0.1.24", + "version": "0.1.25", "description": "Enterprise-grade runtime for running OpenClaw AI assistants safely on Azure", "license": "MIT", "repository": { diff --git a/docs/getting-started.md b/docs/getting-started.md index 8eebd78a5..bc114b875 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ One `npm i` and one `kars dev`, and you're talking to a secured AI agent on your laptop in about five minutes — no Azure account required. -This guide takes you from a clean machine to a working kars agent (v0.1.18) in two steps: +This guide takes you from a clean machine to a working kars agent in two steps: 1. **[Local — five minutes](#step-1--local-five-minutes)** — `kars dev` runs a real sandbox on your laptop. The **recommended dev loop is a local [kind](https://kind.sigs.k8s.io/) cluster** (`--target local-k8s`) because it reproduces the production pod shape — separate router container, `NetworkPolicy`, seccomp — and behaves almost identically to AKS. A single-container **Docker** target is also available for the fastest possible smoke test. No Azure subscription either way. 2. **[AKS — half an hour](#step-2--deploy-to-aks)** — when you're ready for production, `kars up` provisions AKS + ACR + Foundry + the kars control plane in your subscription, and runs the same sandbox under Workload Identity, NetworkPolicies, and the egress guard. @@ -31,7 +31,7 @@ The sandbox YAML you wrote in step 1 runs **unchanged** in step 2 — build loca All four paths need an **inference provider** — you pick one on first run (see [Choosing an inference provider](#choosing-an-inference-provider)). The easiest is a **GitHub Copilot** seat (no Azure account, no PAT). -> **Just want it running right now?** Use the [fastest path](#10-fastest-path--no-compile-published-images) — `npm i -g @kars-runtime/cli@0.1.18` then `kars dev --release`. Everything below about building from source and cloning AGT is **only** for contributors hacking on kars itself — skip it if that's not you. +> **Just want it running right now?** Use the [fastest path](#10-fastest-path--no-compile-published-images) — `npm i -g @kars-runtime/cli@latest` then `kars dev --release`. Everything below about building from source and cloning AGT is **only** for contributors hacking on kars itself — skip it if that's not you. > **AGT mesh prerequisite — source builds only.** Inter-agent E2E > messaging uses the [Microsoft Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit) @@ -102,7 +102,7 @@ No Rust toolchain, no AGT checkout, no GitHub auth, no waiting on a local build. ```bash # 1. Install the kars CLI from npm — public, signed (SLSA provenance), always the latest release -npm i -g @kars-runtime/cli@0.1.18 +npm i -g @kars-runtime/cli@latest # 2. Launch a sandbox from the published images (defaults to :latest) kars dev --release @@ -141,7 +141,8 @@ kars dev --release --target local-k8s # local kind cluster, real K8s posture > sub-agents, E2E-encrypted relay) passes on a stock M-series Mac and on AKS. > **Pin a specific build (optional).** `kars dev --release` follows the newest -> release; pass a tag — `kars dev --release v0.1.18` — to pin a +> release; pass a tag — `kars dev --release ` (e.g. the version shown on the +> [latest release](https://github.com/Azure/kars/releases/latest)) — to pin a > specific build for reproducibility. Want to hack on the controller / router / plugin? Build from source — @@ -263,7 +264,7 @@ kars up --name prod-agent --region swedencentral --release --mesh-trust=entra -- > **`--release` vs `--build`:** `--release` imports the public, cosign-signed > `ghcr.io/azure/*` images into your ACR — no Rust, no Docker build, no source -> checkout to compile (bare `--release` = latest, or pin `--release v0.1.18`). +> checkout to compile (bare `--release` = latest, or pin `--release `). > Drop it to import from a source ACR, or pass `--build` to compile from source > (developer mode; compiles Rust in-Docker on macOS/arm64). diff --git a/docs/mcp.md b/docs/mcp.md index d8717160e..6c05388ea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -174,11 +174,35 @@ For the MCP-specific threat model (tool poisoning, confused-deputy, prompt injection through tool output), see the [MCP security top-10](security-mcp-top10.md). +## Sub-agents inherit MCP access + +When an agent spawns a sub-agent (via the spawn/handoff tools or a skill that +runs work as a child agent), the child **inherits the parent's +`governance.mcpServerRefs`**. The spawn path reads the parent `KarsSandbox`, +copies its effective MCP references (the deprecated singular `mcpServerRef` is +lifted into the plural form), and writes them onto the child's +`spec.governance.mcpServerRefs`. + +Because the child CR is created in the **same namespace** as the parent — the +same place the `McpServer` CRs, `-inference`, and `-toolpolicy` +live — the by-name references resolve without any extra wiring. The controller +then does for the child exactly what it does for the parent: mirrors the +`mcp-{name}-jwks` / `mcp-{name}-signing` material into the child namespace and +[derives the MCP egress rule](#out-of-the-box-egress) from the `McpServer` URL. +So a Playwright-MCP parent spawns children that can drive the browser too — no +per-child `McpServer` CR or manifest edit required. + +This is additive: if the parent references no MCP servers, the child gets none. +The child's `egressMode` still follows the spawn defaults (Strict in +production), which is fine — the derived MCP egress rule is admitted regardless +of mode. + ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Agent says the tool doesn't exist | Tool not in `allowedTools`, or sandbox label doesn't match `allowedSandboxes` | Add the tool / fix the label; re-apply the CR. | +| Spawned **sub-agent** can't see the MCP tools the parent has | Parent's `mcpServerRefs` not inherited (pre-0.1.25) | Upgrade the router; inheritance is automatic. Confirm with `kubectl -n kars- get karssandbox -o jsonpath='{.spec.governance.mcpServerRefs}'`. | | `404 Session not found`, page resets to `about:blank` | Router not keeping the session alive (pre-0.1.24) | Upgrade the router; keepalive is automatic. | | Calls time out to an in-cluster MCP | Egress not admitted (e.g. `ipBlock` under Cilium) | Use the MCP's Service DNS `url` so the controller derives a `namespaceSelector` rule; check `kubectl -n kars- get networkpolicy`. | | `403`/`401` from a hosted MCP | OAuth/bearer misconfigured | Check `oauth.issuer`/`audience` or the `bearerFromEnv` secret. | diff --git a/inference-router/src/spawn/mcp_inherit_test.rs b/inference-router/src/spawn/mcp_inherit_test.rs new file mode 100644 index 000000000..b18822525 --- /dev/null +++ b/inference-router/src/spawn/mcp_inherit_test.rs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Sub-agent MCP inheritance tests. Pulled out of `mod tests` inside +//! `spawn::mod` to keep that file under the §4.2 LOC budget. +//! +//! Pins `parent_mcp_server_refs` (the parent-CR extractor that honors the +//! deprecated singular `mcpServerRef` shim) and the production overlay that +//! copies the parent's `governance.mcpServerRefs` onto a spawned child so +//! sub-agents inherit MCP tool access (e.g. Playwright). + +use super::*; +use std::collections::BTreeMap; + +fn req(agent_id: &str) -> SpawnRequest { + SpawnRequest { + agent_id: agent_id.into(), + model: None, + governance: true, + trust_threshold: None, + learn_egress: false, + isolation: None, + token_budget_daily: None, + token_budget_per_request: None, + trusted_peers: None, + handoff: None, + runtime_kind: None, + role: None, + } +} + +#[test] +fn parent_mcp_refs_reads_plural() { + let data = serde_json::json!({ + "spec": { "governance": { "mcpServerRefs": [ + { "name": "playwright" }, + { "name": "filesystem" }, + ] } } + }); + let refs = parent_mcp_server_refs(&data); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0]["name"], "playwright"); + assert_eq!(refs[1]["name"], "filesystem"); +} + +#[test] +fn parent_mcp_refs_lifts_deprecated_singular() { + let data = serde_json::json!({ + "spec": { "governance": { "mcpServerRef": { "name": "playwright" } } } + }); + let refs = parent_mcp_server_refs(&data); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0]["name"], "playwright"); +} + +#[test] +fn parent_mcp_refs_prefers_plural_over_singular() { + // Plural is authoritative when both are present (mirrors + // GovernanceConfig::effective_mcp_server_refs). + let data = serde_json::json!({ + "spec": { "governance": { + "mcpServerRefs": [ { "name": "plural" } ], + "mcpServerRef": { "name": "singular" }, + } } + }); + let refs = parent_mcp_server_refs(&data); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0]["name"], "plural"); +} + +#[test] +fn parent_mcp_refs_empty_when_absent() { + assert!(parent_mcp_server_refs(&serde_json::json!({})).is_empty()); + assert!( + parent_mcp_server_refs(&serde_json::json!({ "spec": { "governance": {} } })).is_empty() + ); + // Empty plural array must not mask into a stray entry. + assert!( + parent_mcp_server_refs( + &serde_json::json!({ "spec": { "governance": { "mcpServerRefs": [] } } }) + ) + .is_empty() + ); +} + +#[test] +fn inherited_mcp_refs_overlay_onto_child_governance() { + // Reproduces the production overlay: build a child CRD (no MCP refs), + // then apply the parent's inherited refs the way `create_sandbox` does. + let mut crd = build_sub_agent_crd_with_labels( + "parent", + "kars-parent", + "enhanced", + "gpt-5.4", + &req("child"), + &BTreeMap::new(), + ); + assert!( + crd["spec"]["governance"].get("mcpServerRefs").is_none(), + "builder must not invent MCP refs on its own" + ); + + let parent_data = serde_json::json!({ + "spec": { "governance": { "mcpServerRefs": [ { "name": "playwright" } ] } } + }); + let refs = parent_mcp_server_refs(&parent_data); + if !refs.is_empty() + && let Some(gov) = crd + .get_mut("spec") + .and_then(|s| s.get_mut("governance")) + .filter(|g| g.is_object()) + { + gov["mcpServerRefs"] = serde_json::Value::Array(refs); + } + + let got = crd["spec"]["governance"]["mcpServerRefs"] + .as_array() + .expect("child must carry inherited mcpServerRefs"); + assert_eq!(got.len(), 1); + assert_eq!(got[0]["name"], "playwright"); +} diff --git a/inference-router/src/spawn/mod.rs b/inference-router/src/spawn/mod.rs index 050a1b60d..b50c67265 100644 --- a/inference-router/src/spawn/mod.rs +++ b/inference-router/src/spawn/mod.rs @@ -20,6 +20,8 @@ mod docker; #[cfg(test)] mod dev_profile_test; +#[cfg(test)] +mod mcp_inherit_test; pub use docker::{delete_sandbox_docker, list_sandboxes_docker}; fn default_true() -> bool { @@ -216,19 +218,32 @@ pub async fn create_sandbox( // still enough for the sub-agent to be functional; inherited // tags are a quality-of-life feature for operators, not a // governance gate. - let parent_labels: BTreeMap = match api.get(parent_name).await { - Ok(parent_obj) => parent_obj.metadata.labels.unwrap_or_default(), - Err(e) => { - tracing::warn!( - parent = %parent_name, - child = %req.agent_id, - "Could not fetch parent labels for inheritance (non-fatal): {e}" - ); - BTreeMap::new() - } - }; + // + // The same parent fetch also recovers the parent's effective + // `governance.mcpServerRefs` (honoring the deprecated singular shim) so a + // spawned sub-agent doesn't silently lose MCP access — e.g. a Playwright + // sandbox must spawn children that can also drive the browser. The refs + // are by-name into the child CR's namespace (the parent's namespace, where + // the McpServer CRs and `-inference`/`-toolpolicy` already live), + // so propagating them verbatim resolves and the controller mirrors the + // JWKS/signing material + derives the MCP egress rule for the child. + let (parent_labels, parent_mcp_refs): (BTreeMap, Vec) = + match api.get(parent_name).await { + Ok(parent_obj) => { + let labels = parent_obj.metadata.labels.clone().unwrap_or_default(); + (labels, parent_mcp_server_refs(&parent_obj.data)) + } + Err(e) => { + tracing::warn!( + parent = %parent_name, + child = %req.agent_id, + "Could not fetch parent CR for inheritance (non-fatal): {e}" + ); + (BTreeMap::new(), Vec::new()) + } + }; - let crd = build_sub_agent_crd_with_labels( + let mut crd = build_sub_agent_crd_with_labels( parent_name, &namespace, isolation, @@ -237,6 +252,24 @@ pub async fn create_sandbox( &parent_labels, ); + // Additive overlay: copy inherited MCP refs onto the child's governance + // (the builder always emits `spec.governance`). + if !parent_mcp_refs.is_empty() + && let Some(gov) = crd + .get_mut("spec") + .and_then(|s| s.get_mut("governance")) + .filter(|g| g.is_object()) + { + let count = parent_mcp_refs.len(); + gov["mcpServerRefs"] = serde_json::Value::Array(parent_mcp_refs); + tracing::info!( + parent = %parent_name, + child = %req.agent_id, + count, + "Sub-agent inherits parent MCP server refs" + ); + } + let obj: kube::api::DynamicObject = serde_json::from_value(crd).map_err(|e| format!("Failed to build CRD: {e}"))?; @@ -730,6 +763,26 @@ pub(crate) fn inherit_parent_labels( out } +/// Extract the parent sandbox's effective `governance.mcpServerRefs` so a +/// spawned sub-agent inherits the same MCP server access. Honors the +/// deprecated singular `mcpServerRef` (mirrors +/// `GovernanceConfig::effective_mcp_server_refs`). Operates on a +/// `DynamicObject`'s `data` value; empty when the parent references none. +fn parent_mcp_server_refs(parent_data: &serde_json::Value) -> Vec { + let Some(gov) = parent_data.get("spec").and_then(|s| s.get("governance")) else { + return Vec::new(); + }; + if let Some(arr) = gov.get("mcpServerRefs").and_then(|v| v.as_array()) + && !arr.is_empty() + { + return arr.clone(); + } + if let Some(singular) = gov.get("mcpServerRef").filter(|v| v.is_object()) { + return vec![singular.clone()]; + } + Vec::new() +} + pub(crate) fn build_sub_agent_crd_with_labels( parent_name: &str, namespace: &str,