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
4 changes: 2 additions & 2 deletions ci/loc-budget.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

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

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
11 changes: 6 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <tag>` (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 —
Expand Down Expand Up @@ -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 <tag>`).
> Drop it to import from a source ACR, or pass `--build` to compile from source
> (developer mode; compiles Rust in-Docker on macOS/arm64).

Expand Down
24 changes: 24 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<parent>-inference`, and `<parent>-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-<parent-ns> get karssandbox <child> -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-<sandbox> get networkpolicy`. |
| `403`/`401` from a hosted MCP | OAuth/bearer misconfigured | Check `oauth.issuer`/`audience` or the `bearerFromEnv` secret. |
Expand Down
121 changes: 121 additions & 0 deletions inference-router/src/spawn/mcp_inherit_test.rs
Original file line number Diff line number Diff line change
@@ -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");
}
77 changes: 65 additions & 12 deletions inference-router/src/spawn/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String, String> = 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 `<parent>-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<String, String>, Vec<serde_json::Value>) =
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,
Expand All @@ -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}"))?;

Expand Down Expand Up @@ -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<serde_json::Value> {
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,
Expand Down
Loading