diff --git a/.github/workflows/deploy-sam-one.yaml b/.github/workflows/deploy-sam-one.yaml index 05876871..f8d3c8a9 100644 --- a/.github/workflows/deploy-sam-one.yaml +++ b/.github/workflows/deploy-sam-one.yaml @@ -1,7 +1,7 @@ name: Sam-One Cloud Run Sandbox # On-demand sam-one deployments on Cloud Run (see -# site/content/docs/user/cloud-run-deployment.md for the manual version). +# site/content/docs/guides/cloud-run.md for the manual version). # Deploys are gated by the 'sam-one-sandbox' GitHub Environment: required # reviewers approve each run before GCP credentials are minted. # diff --git a/AGENTS.md b/AGENTS.md index 685397c6..9fc0b954 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ You are an expert software engineering assistant helping to develop, maintain, a * **API Communication:** All data communication between `sam-control-plane`, `sam-router` and `sam-node` must happen exclusively via the common API defined in `api/sam.proto`. * **Two API surfaces, two encodings:** the *mesh protocol* — anything a mesh component speaks (node, router, `sam-box`, the agent connector): enrollment, refresh, keys, leases, auth streams, policy sync — is protobuf from `api/sam.proto` (`application/x-protobuf`). The *operator plane* — what humans, the web console and admin CLIs call (`/admin/*`, `/users/*`) — is JSON, with its request and response types declared as Go structs in `api/` (e.g. `api.BootstrapTokenRequest`). Never define a wire shape as an anonymous struct or `map[string]any` inside a handler, and never serialize an `internal/storage` (or any other internal) type onto either surface; clients in `cmd/` and `internal/console` import `api/` only. A message that a mesh component consumes goes in the proto even if a console also reads it. * **Secrets never travel as flag values:** binaries read credentials from a file (`--*-path`) or the environment, never from a command-line argument that would sit in `ps` output and shell history. Banners and logs name the source of an operator-supplied secret instead of echoing it. -* **Sandbox Dataplane:** `sam-box` (one per sandbox) is the single egress policy enforcement point. It holds no libp2p host, no enrollment and no mesh identity, and reaches the mesh exclusively as a client of the local `sam-node` sidecar socket. `nano-init` (PID 1 inside the guest, its own Go module) owns the guest side; its datapath is the `tun2connect` library. The sandbox boundary is a Unix socket speaking named HTTP tunnels: CONNECT (TCP) and connect-udp (UDP) out, `CONNECT ` back in. The authoritative design is `site/content/docs/agent-architecture.md`; do not contradict it. +* **Sandbox Dataplane:** `sam-box` (one per sandbox) is the single egress policy enforcement point. It holds no libp2p host, no enrollment and no mesh identity, and reaches the mesh exclusively as a client of the local `sam-node` sidecar socket. `nano-init` (PID 1 inside the guest, its own Go module) owns the guest side; its datapath is the `tun2connect` library. The sandbox boundary is a Unix socket speaking named HTTP tunnels: CONNECT (TCP) and connect-udp (UDP) out, `CONNECT ` back in. The authoritative design is `site/content/docs/preview/agent-architecture.md`; do not contradict it. * **Enforcement over Convention:** never gate sandbox traffic on the agent's cooperation — no proxy environment variables, no `LD_PRELOAD` shims, no DNS spoofing. The agent harness stays unmodified and mesh-unaware; confinement is a route and a socket, built by the userspace launcher (`nano-init`) and judged in `sam-box`. An agent that must cooperate with its own confinement is not confined. * **Policy on Names:** egress policy, secret injection and routing decisions are made on the destination *name*, never on an IP. Deny by default. * **Agent Identity:** the agent is the principal; the node is only the channel. Agent identity comes from the platform's workload credential, verified at admission — never asserted in-band from inside the sandbox. Platforms integrate solely through the connector interface (`Attach`/`Detach`/`Refresh`/`Status` and the agent bundle), not by reaching into SAM internals. diff --git a/README.md b/README.md index 7a581bf2..24be25df 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,71 @@ -# SAM: Sovereign Agent Mesh - -SAM - -SAM is a smart network built for autonomous AI agents: - -* **Zero Config:** Nodes discover each other and build the P2P network automatically. -* **Zero Trust:** Every connection, node, and packet is strictly authenticated. -* **Agentic Network:** Formed by lightweight nodes (`sam-node`) that provide self-healing, P2P connectivity, allowing autonomous agents to plug in, communicate, and invoke tools dynamically. -* **Portability:** Cryptographic identities are environment-agnostic, allowing seamless node mobility across cloud, local, and edge environments. - -Getting started is a one-liner (see the [Quick Start Guide](site/content/docs/quickstart.md)): install, add the skill, and your agent is on the mesh. - -Demo: installing SAM, adding the sam-mesh skill, and an agent discovering and calling tools across the mesh - -
-Advanced demo: an agent fans a batch of work across a warm pool of reviewer agents on the mesh - - - -Full walkthrough: [Warm Agent Pool use case](site/content/docs/use-cases/warm-agent-pool.md). - -
- ---- - -## What "Sovereign" Means in SAM - -SAM is an **open-source software project (Apache-2.0)** providing decentralized networking and cryptographic building blocks for autonomous AI agents. It carries no vendor telemetry and has no hardcoded dependencies on proprietary cloud services or model providers. - -SAM provides the open protocols, cryptographic building blocks, and software to build **sovereign, zero-trust agent meshes**. In SAM, digital sovereignty is built on architectural and cryptographic control—custody of root keys, independent identity federation, and policy-enforced data boundaries. In a sovereign deployment, operators: - -1. **Deploy Dedicated Mesh Infrastructure:** Run a dedicated control plane (`sam-control-plane`) and routing relays (`sam-router`) on your chosen infrastructure (managed cloud environments like Google Cloud, private Kubernetes clusters, or air-gapped datacenters) using our [Helm chart](charts/sam-mesh/README.md) or Kubernetes manifests. -2. **Maintain Root Cryptographic Key Custody:** Generate, manage, and hold your own Ed25519 root signing keys (via local HSMs, KMS, or Cloud EKM). You maintain 100% of the cryptographic authority—no external party can mint credentials, revoke nodes, or alter policies. -3. **Bring Your Own Identity Provider:** Bridge agent and user identities through your own OIDC identity provider (such as Dex, Keycloak, or corporate IdP). -4. **Enforce Territorial & Jurisdictional Boundaries:** Use cryptographically attested label gates (`labels: {jurisdiction: eu}` in the node config, `X-Sam-Required-Labels`) to mathematically guarantee prompts and tool invocations never leave authorized geographic scopes. -5. **Retain Autonomous Local Vetoes:** Configure local node attenuation policies (`sam-node.yaml`) to evaluate access rules *before* control plane grants, ensuring local nodes retain absolute veto authority. - -> [!NOTE] -> **About the Public Developer Testnets:** -> The public endpoints (`bananas.sam-mesh.dev` and `hub.sam-mesh.dev`) are free testbeds created using community resources solely for developer testing, continuous integration, and rapid experimentation. They provide **no guarantees, no uptime commitments, zero SLA, and no sovereign guarantees**. Running on a shared community testnet delegates identity management to the testbed maintainers; true sovereignty requires deploying a dedicated control plane with customer-held keys. -> -> [![Testnet Health](https://github.com/google/sam/actions/workflows/testnet-health.yaml/badge.svg)](https://github.com/google/sam/actions/workflows/testnet-health.yaml) Every half hour a fresh node's view of each testnet is checked (enrollment surface, routers, canaries, and a cold-path probe that joins and calls a tool); a red badge means one of them is unhealthy and an issue labelled `testnet-health` says which check failed. -> -> 📖 **Deep Dive:** Read our full **[Digital & Data Sovereignty Architecture](site/content/docs/sovereignty.md)** covering the 5 pillars, fail-closed label gates, uncooperative sandbox confinement, and regulatory alignment (GDPR Chapter V, EU Cloud Sovereignty Framework SEAL-3, EU Data Act). - ---- - -## Architecture Components - -* `sam-control-plane`: The registry control plane for node identity registration, authorization policies, and router coordinating. -* `sam-router`: The libp2p bootstrap nodes and relays providing data-plane connectivity and forwarding. -* `sam-node`: The local node clients providing mesh transport integration and MCP sidecar routing. - ---- +# SAM + +SAM + +SAM is a private network for AI agents. A node runs next to an agent and +gives it three things: a way to publish tools and models to the network, a +way to find and call what other nodes publish, and an identity every other +node can verify. Nodes connect directly when they can and through relays when +they cannot, so the network works across laptops, containers, clusters and +phones behind NAT. + +Two properties hold everywhere: + +- **Nothing is reachable by default.** A node exposes no services until its + configuration says so, and no node may call a service the mesh policy has + not granted. Grants are evaluated on service names, never on addresses. +- **Identity comes from your identity provider.** A node enrolls with an + OpenID Connect token or a one-time bootstrap token, and the control plane + turns that into a short-lived, offline-verifiable credential bound to the + node's own key. + +Installing sam-node, adding the skill, and an agent discovering and calling tools across the mesh + +## Try it + +```bash +curl -sL https://sam-mesh.dev/install.sh | bash # sam-node, mcp-client and friends +sam-node join https://bananas.sam-mesh.dev # one-time login +sam-node run --daemonize # local MCP server on 127.0.0.1:8080 +sam-node skill install # teach your agent to use it +``` + +Your agent now has tools that discover and call services across the mesh, +and an OpenAI-compatible endpoint that routes model requests to whoever +serves the model. `bananas.sam-mesh.dev` is a shared developer testnet with +no uptime promise; the [quick start](https://sam-mesh.dev/docs/getting-started/quickstart/) +walks through it, and [your own mesh](https://sam-mesh.dev/docs/getting-started/your-own-mesh/) +runs a control plane on your laptop in one command. + +## What is in a mesh + +| Program | Role | +|---|---| +| `sam-control-plane` | Verifies who is joining, issues each node a signed credential, and holds the policy that says who may call what. | +| `sam-router` | A well-known peer that nodes connect to first. Relays traffic between nodes that cannot reach each other and hosts the discovery table. | +| `sam-node` | Runs next to your agent or service. Enrolls, connects, serves your local backends to the mesh, and exposes the mesh to your agent as a local MCP server and OpenAI-compatible API. | +| `sam-one` | The control plane, a router and a web console in one binary, for laptops and small deployments. | ## Documentation -Start exploring the Sovereign Agent Mesh: - -### Digital & Data Sovereignty -- 🏛️ **[Digital & Data Sovereignty Architecture](site/content/docs/sovereignty.md)**: How SAM enforces data residency, territorial label gates, autonomous local vetoes, and regulatory compliance (GDPR, EU Cloud Sovereignty Framework SEAL-3, EU Data Act). - -### For Users & Operators -- 🚀 **[User Quick Start Guide](site/content/docs/quickstart.md)**: Connect and run a SAM node on the community-hosted developer testnet (`bananas.sam-mesh.dev`, strictly for testing with no guarantees) using binaries or Docker. -- 🎛️ **[Dedicated Sovereign Deployment](site/content/docs/user/kubernetes-deployment.md)**: Run your own private sovereign hub (control plane, router, console) via the [sam-mesh Helm chart](charts/sam-mesh/README.md) or Kubernetes manifests. -- 🤖 **[Agent Integration Guides](site/content/docs/integrations/_index.md)**: Connect Google Gemini, Claude, and other AI agents to your SAM node to dynamically discover and call tools across the mesh. -- 📡 **[Testnet Validation Tutorial](site/content/docs/development/testnet-validation.md)**: Real-time verification, remote tool invocation, and HTTP stream proxies on public developer testnets. - -### For Developers & Contributors -Compile from source, run local clusters, or execute tests: -- 🛠️ **[Developer Guide](site/content/docs/development/_index.md)**: Prereqs, compilation, local control plane setup, and Kubernetes Kind deployment. -- 🧪 **[Testing Guide](site/content/docs/development/testing.md)**: Go tests, E2E BATS, and containerized mesh execution. +[sam-mesh.dev/docs](https://sam-mesh.dev/docs/) is built from `site/`. ---- +- [Getting started](https://sam-mesh.dev/docs/getting-started/): one node on the testnet, then a mesh of your own. +- [Concepts](https://sam-mesh.dev/docs/concepts/): architecture, identity and enrollment, authorization, networking. +- [Guides](https://sam-mesh.dev/docs/guides/): exposing services, connecting agent clients, headless enrollment, Kubernetes, Cloud Run. +- [Reference](https://sam-mesh.dev/docs/reference/): every flag, configuration key, HTTP route and policy field. +- [Preview](https://sam-mesh.dev/docs/preview/): sandboxed agents and the mobile app, which work but are still settling. +- [Contributing](https://sam-mesh.dev/docs/contributing/): building, testing and the local kind environment. -## License +## Status -See [LICENSE](LICENSE). +SAM is pre-1.0. The node, routers, control plane, identity and policy model +are stable in shape and exercised by the test suite and two public testnets; +see [ROADMAP.md](ROADMAP.md) for the release plan. Sandboxed agents +(`sam-box`, `nano-init`) and the mobile app are in preview. -## Disclaimer +## License and disclaimer -This is not an officially supported Google product. This project is not eligible for the [Google Open Source Software Vulnerability Rewards Program](https://bughunters.google.com/open-source-security). +Apache-2.0; see [LICENSE](LICENSE). This is not an officially supported +Google product, and it is not eligible for the +[Google Open Source Software Vulnerability Rewards Program](https://bughunters.google.com/open-source-security). diff --git a/ROADMAP.md b/ROADMAP.md index f7125fd2..43b03320 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,4 +1,4 @@ -# Sovereign Agent Mesh (SAM) Roadmap +# SAM Roadmap ## Phase 1: Alpha @@ -11,7 +11,7 @@ The Alpha phase is focused on laying the foundational architecture, finalizing A * Establish P2P routing and base node lifecycle. * Deploy community developer testnets. - * Draft initial sovereignty architectural documentation. + * Draft the initial architecture documentation. * Integrate baseline network services: MCP, A2A, Inference. * **Exit Criteria:** @@ -35,7 +35,7 @@ The Beta phase iterates on functional completeness based on real-world deploymen * **Exit Criteria:** - * All planned sovereignty features are implemented and passing integration tests. + * All planned data-residency and control features are implemented and passing integration tests. * API and Datalog schemas are frozen. ## Phase 3: The Audit Freeze (Release Candidates) @@ -44,24 +44,24 @@ The Beta phase iterates on functional completeness based on real-world deploymen **Status:** Feature Frozen / Undergoing Audit During this phase, **no new features are merged**. -The codebase is strictly locked down to undergo rigorous external validation to ensure it meets our security and sovereignty guarantees. +The codebase is strictly locked down to undergo rigorous external validation to ensure it meets our security and data-residency guarantees. * **Core Objectives:** * **Security Audit:** Comprehensive third-party penetration testing, code review, and cryptography validation (focusing on Biscuit tokens and Ed25519 key handling). - * **Sovereignty & Compliance Audit:** Validation against strict digital sovereignty frameworks (e.g., GDPR data residency, EU AI Act traceability, CSF SEAL-3). + * **Compliance Audit:** Validation against data-residency and traceability frameworks (e.g., GDPR data residency, EU AI Act traceability, CSF SEAL-3). * **Exit Criteria:** * All critical and high-severity security vulnerabilities are remediated. - * External auditors officially sign off on the cryptographic and architectural sovereignty claims. + * External auditors officially sign off on the cryptographic and architectural claims. ## Phase 4: Production Stable **Versions:** `v1.0.0` and beyond **Status:** Mission-Critical Ready -The framework is certified for enterprise, sovereign, and cross-border deployments. +The framework is certified for enterprise, regulated, and cross-border deployments. * **Core Objectives:** diff --git a/agents/skills/sam-a2a-bridge/SKILL.md b/agents/skills/sam-a2a-bridge/SKILL.md index 233c69a7..0d9b2bff 100644 --- a/agents/skills/sam-a2a-bridge/SKILL.md +++ b/agents/skills/sam-a2a-bridge/SKILL.md @@ -1,6 +1,6 @@ --- name: sam-a2a-bridge -description: "Use when the task should be delegated to a remote A2A agent on the SAM (Sovereign Agent Mesh) network: send it work with send_agent_task (with text, structured data, or file attachments), check agent capabilities with get_agent_card, poll results with get_agent_task, hold multi-turn conversations, and enforce data-sovereignty labels on every call. Also use to set up the sam-a2a-bridge MCP server when those tools are not callable yet." +description: "Use when the task should be delegated to a remote A2A agent on the SAM agent mesh: send it work with send_agent_task (with text, structured data, or file attachments), check agent capabilities with get_agent_card, poll results with get_agent_task, hold multi-turn conversations, and enforce data-residency labels on every call. Also use to set up the sam-a2a-bridge MCP server when those tools are not callable yet." --- # SAM A2A Bridge Skill @@ -71,7 +71,7 @@ input schema) and `file_path` attachments appropriately. - `data` (optional) contains structured JSON returned inline. - `files` (optional) is a list of paths where agent-returned files are saved under the download directory. -- **Sovereignty**: when the task involves data that must stay in a region or +- **Data residency**: when the task involves data that must stay in a region or jurisdiction, set `required_labels` (comma-separated `key=value`, e.g. `region=eu-west-1`). The local node then refuses fail-closed before any data leaves it unless the peer's control-plane-attested labels match. Never drop @@ -96,7 +96,7 @@ message while running and its answer or artifacts when completed. `data` and ## Interpret Errors -- `403: Required labels not attested by provider` — the sovereignty gate +- `403: Required labels not attested by provider` — the label gate refused before egress. Expected for non-matching regions; report it to the user, do not retry with weaker labels on your own. - `400: Invalid X-Sam-Required-Labels header ...` — malformed labels; fix the diff --git a/agents/skills/sam-mesh/SKILL.md b/agents/skills/sam-mesh/SKILL.md index 0725a892..ef4d87bb 100644 --- a/agents/skills/sam-mesh/SKILL.md +++ b/agents/skills/sam-mesh/SKILL.md @@ -1,6 +1,6 @@ --- name: sam-mesh -description: "Use when local tools cannot provide a needed capability and a SAM (Sovereign Agent Mesh) network can: inspect mesh state, discover reachable services/tools, describe and call namespaced remote MCP tools, and reach OpenAI-compatible inference models hosted by mesh peers. Also use to set up, join, or reconnect a sam-node when its MCP tools are not callable yet." +description: "Use when local tools cannot provide a needed capability and a SAM agent mesh can: inspect mesh state, discover reachable services/tools, describe and call namespaced remote MCP tools, and reach OpenAI-compatible inference models hosted by mesh peers. Also use to set up, join, or reconnect a sam-node when its MCP tools are not callable yet." --- # SAM Agent Skill diff --git a/charts/sam-mesh/Chart.yaml b/charts/sam-mesh/Chart.yaml index e89a669e..59fbb336 100644 --- a/charts/sam-mesh/Chart.yaml +++ b/charts/sam-mesh/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: sam-mesh -description: A Helm chart for deploying the Sovereign Agent Mesh (SAM) Control Plane, Router, and Console +description: A Helm chart for deploying the SAM control plane, router and console type: application version: 0.1.0 appVersion: "1.0.0" diff --git a/charts/sam-mesh/README.md b/charts/sam-mesh/README.md index 807a2c79..ba3a017d 100644 --- a/charts/sam-mesh/README.md +++ b/charts/sam-mesh/README.md @@ -4,10 +4,9 @@ Deploys a self-contained SAM mesh (control plane, router, console, and an in-cluster Postgres) for local development, testing, or self-hosting your own mesh. -> For large-scale production deployments (GKE/EKS/AKS) using externally -> managed Postgres/DNS/OIDC, see the -> [Production Kubernetes Deployment guide](https://sam-mesh.dev/docs/user/kubernetes-deployment/), -> which uses plain manifests instead of this chart. +> For deployments on GKE/EKS/AKS with externally managed Postgres, DNS and +> OIDC, see the +> [Kubernetes guide](https://sam-mesh.dev/docs/guides/kubernetes/). ## Install @@ -65,7 +64,7 @@ Defaults to `true` (any node/router presenting a valid identity token is enrolled immediately, no manual step). Set to `false` if you want an administrator to approve each enrollment via `/admin/enrollments` before a node can join — see the -[Control Plane Configuration guide](https://sam-mesh.dev/docs/user/control-plane-configuration/#6-headless-node-enrollment-bootstrap-token-flow). +[Headless enrollment guide](https://sam-mesh.dev/docs/guides/headless-enrollment/). ## Gateway API (`gateway.enabled`) diff --git a/cmd/nano-init/README.md b/cmd/nano-init/README.md index 4c63d94b..226d1710 100644 --- a/cmd/nano-init/README.md +++ b/cmd/nano-init/README.md @@ -82,7 +82,7 @@ image that has nothing else in it. ## See also -- [Running agents on SAM](https://sam-mesh.dev/docs/user/running-agents/) — the +- [Sandboxed agents](https://sam-mesh.dev/docs/preview/sandboxed-agents/) — the full picture, including the microVM arrangement -- [Agent architecture](https://sam-mesh.dev/docs/agent-architecture/) — why the +- [Agent architecture](https://sam-mesh.dev/docs/preview/agent-architecture/) — why the boundary speaks named HTTP tunnels diff --git a/cmd/sam-node/skill.go b/cmd/sam-node/skill.go index 695782b3..bbb6a8f9 100644 --- a/cmd/sam-node/skill.go +++ b/cmd/sam-node/skill.go @@ -179,7 +179,7 @@ Next steps: --header "X-Sam-Authentication: Bearer " Antigravity add that URL as "serverUrl", with the same header, to ~/.gemini/config/mcp_config.json - Other agents: https://sam-mesh.dev/docs/integrations/ + Other agents: https://sam-mesh.dev/docs/guides/connecting-agents/ 3. Restart your agent so it picks up the skill and the mesh tools. `) } diff --git a/development/examples/agent-harness/README.md b/development/examples/agent-harness/README.md index f078034f..c1de9ed4 100644 --- a/development/examples/agent-harness/README.md +++ b/development/examples/agent-harness/README.md @@ -5,7 +5,7 @@ conversation and the tool loop, and nothing else. No model endpoint to configure, no API key, no tool servers deployed alongside it, and no network beyond what its policy names. -Full walkthrough: [Running agents on SAM](https://sam-mesh.dev/docs/user/running-agents/). +Full walkthrough: [Sandboxed agents](https://sam-mesh.dev/docs/preview/sandboxed-agents/). ## What it demonstrates diff --git a/mobile/sam-node-app/README.md b/mobile/sam-node-app/README.md index 99d1354f..e2610c59 100644 --- a/mobile/sam-node-app/README.md +++ b/mobile/sam-node-app/README.md @@ -97,7 +97,7 @@ Publishing is keyless: [`hack/publish-play.sh`](../../hack/publish-play.sh) driv Once launched, the app (installed as **SAM Connect**, application id `dev.sammesh.connect`) asks you to enroll, then shows the dashboard: -1. **Scan enrollment code**: The primary path. `sam-one` prints a single-use `sam://enroll?server=...&token=...` QR code at startup (and on demand with `sam-one token qr`); scan it, confirm the control plane hostname, and the app enrolls through `POST /enroll` with no identity provider involved. The phone's stock camera app can scan it too: the `sam://` link opens SAM Connect. Only `https://` control planes are accepted (plaintext `http://` for loopback only), because the control plane is the device's trust root. See [A Mesh in 30 Seconds](../../site/content/docs/user/device-enrollment.md). +1. **Scan enrollment code**: The primary path. `sam-one` prints a single-use `sam://enroll?server=...&token=...` QR code at startup (and on demand with `sam-one token qr`); scan it, confirm the control plane hostname, and the app enrolls through `POST /enroll` with no identity provider involved. The phone's stock camera app can scan it too: the `sam://` link opens SAM Connect. Only `https://` control planes are accepted (plaintext `http://` for loopback only), because the control plane is the device's trust root. See [Your own mesh](../../site/content/docs/getting-started/your-own-mesh.md). 2. **Enter details manually**: Paste a whole `sam://enroll` link or a bare bootstrap token together with the **Control plane URL**, then **Join with token**. On a full control plane, mint the token with `POST /admin/bootstrap-tokens` (see `development/kind/run-local-node.sh`); on `sam-one`, `sam-one token create`. Below it, **Login & Enroll (Browser)** and **Device Login** are the OIDC alternatives: the app reads the issuer from the control plane's `/info` endpoint and opens a login. 3. **Local API Token**: The bearer token that secures the local sidecar REST API. It is generated on first launch and kept in the app's private storage; view, copy or regenerate it on the **Config** tab. There is no default: Android loopback is shared by every installed app, so a fixed value would let any of them act as this node. 4. **Labels**: Set on the **Config** tab *before* enrolling; they are attested into the node's Biscuit at that point. @@ -133,7 +133,7 @@ Exposes capabilities directly to the OS registry, allowing native assistants (li 1. **Dashboard Tab**: Displays current status, Node ID, connected peers, and DHT size. 2. **Services Tab**: Allows enabling/disabling embedded sensors (Battery/Location) and bridging external local MCP servers. -3. **Config Tab**: The node's labels and attenuation, the app's stand-in for `sam-node.yaml`. Labels (comma-separated `key=value`) are set here; the control plane attests them into the node's Biscuit at enrollment, so changing them later means pressing Re-enroll, which re-attests them with the login session saved at enrollment (the browser opens only if that session has expired) and keeps the node's identity. A node that joined with a token or QR code has no login session and the control plane re-mints from the labels already on record, so Re-enroll is disabled for it: unenroll and join again with the new labels. Attenuation is Datalog rules, policies and checks that limit who may call this node, one statement per line (e.g. `check if label("region", "eu-west-1");`). They are read when the node starts, and a syntax error fails the start. Same syntax and same errors as the `attenuation` block of `sam-node.yaml`; see [Node configuration §3](../../site/content/docs/user/node-configuration.md#3-defining-local-security-target-attenuation). +3. **Config Tab**: The node's labels and attenuation, the app's stand-in for `sam-node.yaml`. Labels (comma-separated `key=value`) are set here; the control plane attests them into the node's Biscuit at enrollment, so changing them later means pressing Re-enroll, which re-attests them with the login session saved at enrollment (the browser opens only if that session has expired) and keeps the node's identity. A node that joined with a token or QR code has no login session and the control plane re-mints from the labels already on record, so Re-enroll is disabled for it: unenroll and join again with the new labels. Attenuation is Datalog rules, policies and checks that limit who may call this node, one statement per line (e.g. `check if label("region", "eu-west-1");`). They are read when the node starts, and a syntax error fails the start. Same syntax and same errors as the `attenuation` block of `sam-node.yaml`; see [Node configuration](../../site/content/docs/reference/node-config.md#attenuation). --- diff --git a/sam-mcp-python/README.md b/sam-mcp-python/README.md index 916f2e89..2a1c8131 100644 --- a/sam-mcp-python/README.md +++ b/sam-mcp-python/README.md @@ -1,6 +1,6 @@ # SAM Python SDK (sam-mcp-python) -The official Python SDK for the Sovereign Agent Mesh (SAM). +The official Python SDK for SAM. This SDK acts as a "Thin Client" that connects to the local Go node via a Unix Domain Socket and communicates using the Model Context Protocol (MCP) over JSON-RPC 2.0. diff --git a/sam-mcp-python/pyproject.toml b/sam-mcp-python/pyproject.toml index f5bd8e41..73bb959c 100644 --- a/sam-mcp-python/pyproject.toml +++ b/sam-mcp-python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "sam-mcp" version = "0.1.0" -description = "Python SDK for Sovereign Agent Mesh (SAM) using MCP" +description = "Python SDK for SAM agent mesh nodes, using MCP" readme = "README.md" requires-python = ">=3.10" dependencies = [ diff --git a/site/content/_index.md b/site/content/_index.md index 97f6275d..677769f9 100644 --- a/site/content/_index.md +++ b/site/content/_index.md @@ -1,9 +1,13 @@ --- title: SAM -description: Sovereign Agent Mesh - A zero-config, zero-trust decentralized mesh network built for autonomous AI agents. +description: A private, zero-trust network for AI agents to publish, discover and call tools and models across machines. --- -SAM (Sovereign Agent Mesh) provides a secure, zero-trust P2P network specifically designed for AI agents to discover, share, and invoke tools across machines. +SAM is a private network for AI agents. A node runs beside an agent, publishes +the tools and models it offers, finds what other nodes offer, and calls them +over authenticated peer-to-peer connections, through relays when the machines +cannot reach each other directly. -Think of it as a private, zero-trust overlay network tailored for agent-to-agent communication. - -**Secure by Default**: You do not join a mesh automatically, and your tools are never exposed by default. SAM relies on a Zero-Trust architecture, meaning you are 100% isolated until you explicitly join a mesh and allow access. You can use our public testnet for "Easy Mode" testing, or run completely in "DIY Mode" by hosting your own control plane. +Nothing is reachable by default: a node exposes no services until told to, and +no node may call a service the mesh policy has not granted. Identity comes from +your identity provider, and the control plane that turns it into mesh +credentials is one you can run yourself. diff --git a/site/content/docs/_index.md b/site/content/docs/_index.md index 840c3b9c..d30b0768 100644 --- a/site/content/docs/_index.md +++ b/site/content/docs/_index.md @@ -2,43 +2,59 @@ title: "SAM Documentation" linkTitle: "Documentation" --- -SAM (Sovereign Agent Mesh) is a smart, zero-config, zero-trust P2P network built for autonomous AI agents. Think of it as a modern, zero-trust overlay network (similar to a private VPN), but specifically designed and scoped for agent-to-agent tool sharing and communication. -## Why SAM? - -Instead of exposing your agent's tools (like local scripts, LLM endpoints, or internal APIs) to the public internet, SAM allows you to create secure, private mesh networks. This is especially critical because modern AI agents operate across highly heterogeneous environments—spanning cloud servers, on-premises datacenters, personal laptops, Raspberry Pis, and Android devices. SAM seamlessly connects them all, regardless of complex network topologies or NATs. - -**Security & Sovereignty Fundamentals** -* **Open Source Project:** SAM is an open-source project (Apache-2.0). It carries no vendor telemetry, has no proprietary lock-in, and can be deployed on any cloud, on-premises datacenter, or air-gapped environment. -* **Isolated by Default:** You do NOT join any mesh by default. You must explicitly configure the control plane you want to join. -* **Closed by Default:** Joining a mesh does not expose your tools. By default, your node does not allow any services to be reached by others. You must explicitly configure policies to share tools. -* **Public Testnets vs. Sovereign Deployments:** Public testnets (`bananas.sam-mesh.dev`, `hub.sam-mesh.dev`) are created using donated community resources solely as developer playgrounds for CI and experimentation. They hold zero production value, offer no guarantees or SLA, and carry no sovereignty. True sovereignty is achieved by deploying a dedicated control plane and routers with customer-managed cryptographic keys on your chosen infrastructure. -* 🏛️ **[Digital & Data Sovereignty Architecture](sovereignty/)**: Read our detailed breakdown on territorial attestation, jurisdictional label gates, local vetoes, and regulatory alignment. - ---- - -## Where to Start - -### "Developer Testnet Mode" (Ephemeral Testing Sandbox) -For the fastest way to get started and experiment with the open-source code, you can connect a node to our public developer testnet (`bananas.sam-mesh.dev`). *Note: This is a shared developer testbed created with community resources. There are no guarantees; it is strictly for testing. Do not expose sensitive or production tools in a public testing environment.* - -{{< demo >}} - -- 🚀 **[User Quick Start Guide](quickstart/)**: Connect and run a SAM node using binaries or Docker, and query the local MCP server. -- 🤖 **[Agent Integration Guide](user/agent-usage/)**: Connect Google Gemini, Claude, and other AI agents to your SAM node to call tools across the mesh. -- 📡 **[Testnet Validation Tutorial](development/testnet-validation/)**: Real-time verification, remote tool invocation, and HTTP stream proxies. - -### "Dedicated Sovereign Deployment Mode" (For Production & Private Meshes) -Deploy your own control plane, manage your own keys, and retain 100% cryptographic authority: -- 🏛️ **[Digital & Data Sovereignty Guide](sovereignty/)**: Deep-dive into jurisdictional label gates, local veto attenuation, uncooperative sandbox boundaries, and compliance. -- 🎛️ **[Production Kubernetes Deployment](user/kubernetes-deployment/)**: Deploy your own control plane and routers via Helm or Kubernetes manifests. -- 🛠️ **[Developer Guide](development/)**: Prereqs, compilation, local control plane setup, and Kind deployment. -- 🧪 **[Testing Guide](development/testing/)**: Go tests, E2E BATS, and containerized mesh execution. - ---- - -## Architecture - -* **`sam-control-plane`**: The control plane for identity mapping, token issuing, and policy distribution. -* **`sam-router`**: The GossipSub routing overlays and bootstrap points for the P2P mesh. -* **`sam-node`**: The P2P nodes providing the mesh transport layer, self-healing connectivity, and local Model Context Protocol (MCP) HTTP interfaces. +SAM is a private network for AI agents. A node runs +next to an agent and gives it three things: a way to publish tools and +models to the network, a way to find and call what other nodes publish, and +an identity that every other node can verify. Nodes reach each other +directly when they can and through relays when they cannot, so the network +works across laptops, containers, clusters and phones behind NAT. + +Three programs make up a mesh: + +| Program | Role | +|---|---| +| `sam-control-plane` | Verifies who is joining, issues each node a signed credential, and holds the policy that says who may call what. | +| `sam-router` | A well-known peer that new nodes connect to first. It relays traffic between nodes that cannot reach each other and hosts the discovery table. | +| `sam-node` | Runs next to your agent. It enrolls with the control plane, connects to the mesh, serves your local tools to others, and exposes the mesh to your agent as a local MCP server and an OpenAI-compatible API. | + +`sam-one` bundles the first two, plus a web console, into a single binary for +laptops and small deployments. + +Two rules apply to every mesh: + +- **Closed by default.** A node does not expose any service until you + configure one, and a node cannot call a service unless the mesh policy + grants it. Policy is written in terms of service names, not IP addresses. +- **Identity comes from your identity provider.** A node enrolls with an + OpenID Connect token or with a one-time bootstrap token. The control plane + turns that into a short-lived credential + ([Biscuit](https://www.biscuitsec.org/)) that is bound to the node's own + key and that any node can verify offline. + +## How these pages are organised + +- [Getting started](getting-started/) puts one node on the public + testnet, then shows you how to run a mesh of your own with `sam-one`. +- [Concepts](concepts/) explains how the pieces fit together: the + components, enrollment and identity, authorization, and how traffic moves. +- [Guides](guides/) show how to do specific tasks: expose a service, connect + an agent client, enroll headless nodes, deploy on Kubernetes or Cloud Run. +- [Reference](reference/) lists every flag, configuration key, HTTP route + and policy field. +- [Use cases](use-cases/) are worked examples of things built on top of + the mesh. +- [Preview](preview/) covers features that work today but whose interfaces + may still change: sandboxed agents and the mobile app. +- [Contributing](contributing/) covers building, testing and changing SAM + itself. + +## About the public testnets + +`bananas.sam-mesh.dev` (built from `main`) and `hub.sam-mesh.dev` (built from +the latest release tag) are shared developer testnets that run on donated +resources. They exist for trying things out and for the project's own CI. +They have no uptime commitment, and anyone who can log in with the +configured identity provider can join them. Do not publish anything +sensitive there. If you need a mesh that you control, run your own control +plane. The guides explain how. diff --git a/site/content/docs/agent-architecture.md b/site/content/docs/agent-architecture.md deleted file mode 100644 index be383924..00000000 --- a/site/content/docs/agent-architecture.md +++ /dev/null @@ -1,1023 +0,0 @@ ---- -title: "Agent Architecture" -weight: 15 ---- - -# Agent Sandbox Architecture - -This document defines how an **agent** — an unmodified harness such as -`cmd/chaos-agent`, a LangChain script, or Claude Code — is connected to the -Sovereign Agent Mesh when it runs inside a sandbox (a Firecracker microVM or a -`--network none` container). - -It is a design document: it states the layering, the contracts each layer -exposes, and the tests that hold those contracts in place. - -What the result costs is measured in -[Scale: what an agent costs]({{< relref "/docs/scale-report" >}}). - -## 1. Goals - -1. **The harness is unmodified and mesh-unaware.** It opens ordinary sockets, it - resolves ordinary names, it speaks ordinary HTTP. No SDK, no `LD_PRELOAD`, no - proxy environment variables it has to honour. -2. **One enforcement point.** Every packet the sandbox emits is authorized in - exactly one place, by name, against one policy. -3. **The sandbox has no network.** Not a filtered network — *no* network device - other than the one we hand it. Isolation is a property of the runtime - (`network=none`, a microVM without a tap device), not of a firewall rule. -4. **The mesh looks like the internet.** Reaching a remote model or a remote MCP - server is the same act as reaching `api.github.com`: connect to a name. Which - names are mesh-internal and which are public is a policy decision made on the - host, not a code path in the agent. -5. **Deps stay put.** The design below adds no `go.mod` entry (see §7). - -## 2. Why one enforcement point - -There are three common ways to intercept a sandbox's traffic at the application -layer. Each of them leaks, and that is why none of them is used here. - -* **Proxy environment variables** are honoured only by HTTP-aware clients. A - harness that opens a raw socket, a Python library using `httpx` with - `trust_env=False`, or anything speaking gRPC or Postgres simply escapes. -* **DNS spoofing** forces every flow through a MITM even when no secret needs - injecting, and it answers with loopback addresses, which destroys the - destination name the policy engine exists to reason about. -* **An `LD_PRELOAD` socket shim** must be built and served for every arch/libc - pair, and a statically linked binary defeats it outright. - -The shared failure is that each one asks the agent to cooperate with its own -confinement. An agent that has to *agree* to be confined is not confined: the -next library that ignores the convention, the next subprocess that clears its -environment, and the next protocol that is not HTTP are each outside the -boundary. - -Routing does not have that failure mode, because nothing has to agree to it. So -the boundary is a route and a socket rather than a convention, and there is -exactly one of them — one place where a flow is named, decided and opened. - -## 3. The layering - -One socket, one protocol, one policy point. Named HTTP tunnels are the waist. - -```text -┌─ sandbox (microVM, or container with network=none) ───────────────┐ -│ │ -│ harness (unmodified: sockets + DNS + HTTP) │ -│ │ IP │ -│ tun0 ── default route, the only device besides lo │ -│ │ │ -│ tun2connect ── IP flows → CONNECT (TCP) / connect-udp (UDP), │ -│ │ destination kept as a NAME │ -│ │ (virtual DNS, see Decision 2) │ -└────────┼──────────────────────────────────────────────────────────┘ - │ one byte stream per flow - │ microVM : AF_VSOCK → firecracker → host UDS _ - │ container: bind-mounted UDS -┌────────┼──────────────────────────────────────────────────────────┐ -│ host ▼ │ -│ sam-box (one per sandbox, NO libp2p host, NO enrollment) │ -│ = CONNECT server = THE policy enforcement point │ -│ │ │ -│ ├── mesh.sam.alt ── /v1 + /mcp only ─────┐ │ -│ ├── ..sam.alt ── HTTP: discover, then ┤ │ -│ │ /sam///… │ │ -│ └── anything else ── deny-by-default domain │ │ -│ policy, opt-in MITM + │ │ -│ secret injection │ │ -│ │ ▼ │ -│ │ sam-node sidecar │ -│ ▼ UDS: /mcp /v1/* │ -│ the internet /sam/* │ -└─────────────────────────────────────────────────────┼─────────────┘ - │ libp2p - mesh -``` - -### Decision 1 — named HTTP tunnels are the sandbox boundary protocol - -The boundary speaks authority-form `CONNECT` (RFC 9110) for TCP and -connect-udp (RFC 9298, capsules per RFC 9297) for UDP. An earlier revision of -this document chose SOCKS5 over HTTP proxying, for three properties; the -reversal is worth being explicit about, because all three properties are kept -and the reasons the comparison once favoured SOCKS5 stopped being true: - -* **It carries the destination name.** `CONNECT api.github.com:443` is - authority-form: the gateway authorizes `api.github.com`, not - `140.82.121.5`. Domain filtering still needs no DNS interception, no MITM - and no IP allowlists that rot. connect-udp carries the name the same way, - in the URI template `/.well-known/masque/udp/{host}/{port}/`. -* **It is protocol-agnostic.** After the `200 OK` the tunnel is a byte pipe. - `git`, Postgres, gRPC and a raw socket are all first-class; HTTP is the - *handshake*, not a requirement on the payload. -* **It has a refusal vocabulary.** A policy denial is `403` with a - `Boundary-Reason` header, which tun2connect turns into a clean - `ECONNREFUSED` in the guest — exactly what an agent should observe when it - reaches for something it may not have — while an operator reading a log - sees the reason in words. - -What SOCKS5 could not offer, and what decided the reversal: - -* **One protocol in both directions.** The reverse channel into a sandbox - (§9.2) always spoke `CONNECT `; now egress and ingress are the same - shape, and there is one grammar to reason about on the whole boundary. -* **Headers are the extension point.** Sandbox identity for multiplexing - rides in `Proxy-Authorization` (§8.5), and anything else — tracing, a - sandbox id — is a header away. SOCKS5 had a fixed binary vocabulary and - nowhere to put any of that. -* **UDP fits.** `UDP ASSOCIATE` presumes a datagram socket beside the TCP - one, which never fit a boundary that is a single Unix socket. connect-udp - frames datagrams as capsules *on the same stream*, so UDP arrives named, - policy-checked and audit-delimited like every TCP flow (§11). -* **It is curl-testable.** `curl --proxy` speaks the TCP half natively, which - makes the boundary probeable with a tool every operator already has. - -HTTP MITM does not disappear; it moves *above* the waist. When a domain is -configured for secret injection, the gateway terminates TLS for that flow with -the existing `internal/sambox` ephemeral CA and injects the header. Every other -flow is a byte pipe with no interception at all — a meaningful improvement over -the status quo, where every flow is MITM'd. - -### Decision 2 — names are resolved on the host, never in the guest - -tun2connect answers guest DNS from synthetic pools it invents — IPv4 out of -`100.64.0.0/10` (CGNAT space: link-local would be leak-proof, but SSRF guards -in HTTP clients commonly block `169.254/16` and would break legitimate -egress), IPv6 out of `100::/64` (the RFC 6666 discard prefix, so an escaped -packet blackholes). The guest resolver gets a synthetic address, the engine -maps it back to the FQDN when the flow opens, and the gateway receives the -FQDN in the tunnel request. A flow whose destination was never resolved has -no name and is refused in the guest. Nothing in the guest ever performs a -real DNS lookup, so: - -* `nano-init`'s DNS spoofer, `/etc/resolv.conf` rewrite, `HTTP_PROXY` injection - and `libinterceptor.so` bootstrap are **deleted**, not reimplemented; -* the guest cannot exfiltrate over DNS, because there is no resolver path out; -* the policy engine gets the name for free, on every flow, for every protocol. - -### Decision 3 — the host never speaks AF_VSOCK - -Firecracker's vsock device already terminates AF_VSOCK and hands the host a Unix -socket per guest port (`_`). The gateway therefore accepts on a -`net.Listener` that is *always* a UDS. A microVM and a `network=none` container -become the same code path, and the difference is one line of sandbox setup: - -| sandbox | guest side | host side | -|---|---|---| -| Firecracker microVM | `tun2connect` → vsock CID 2, port *P* | listener on `_P` | -| container, `network=none` | `tun2connect` → `/run/sam/agent.sock` | listener on the bind-mounted path | - -This keeps AF_VSOCK out of the Go code entirely (no new dependency) and — more -importantly for the test pyramid — makes the whole datapath exercisable in a Go -integration test against a UDS in `t.TempDir()`, with no VM and no KVM. - -### Decision 4 — a mesh name is the service namespace written in DNS shape - -The mesh already has a canonical namespace: `mcp://`, -`inference://`, `system://sam.catalog`, and internally -`libp2p://///…`. That namespace is what the control plane -authorizes in `allowed_services` and what lands in the Biscuit `service()` fact. -Sandbox names are a **projection of it**, not a second namespace: - -```text -inference://openrouter <-> openrouter.inference.sam.alt -mcp://code-reviewer <-> code-reviewer.mcp.sam.alt -(the mesh, curated) <-> mesh.sam.alt -``` - -The mapping is declared once, in [`api/names.go`](https://github.com/google/sam/blob/main/api/names.go) -(`MeshZone`, `MeshEntrypointHost`, `MeshHost`, `ParseMeshHost`), so the gateway -and the node cannot drift. The rule that keeps it honest: **never introduce a -routing decision expressible in one form but not the other.** - -`.alt` is the pseudo-TLD reserved by RFC 9476 for namespaces that are -deliberately *not* resolved through the DNS — which is exactly what these are. -It cannot collide with a delegated gTLD, and a name that escapes a sandbox fails -closed instead of resolving to somebody else's host. `.local` was rejected: it -is mDNS's (RFC 6762) and resolvers treat it specially. - -Provider selection is *not* in the name. `openrouter.inference.sam.alt` says -which service, never which peer; that stays a discovery decision, scored by the -existing provider scorer. Pinning a peer would mirror `libp2p:///…` as a -longer name, but needs a DNS-safe peer encoding first — base58 peer IDs are -case-sensitive and DNS labels are not. IPFS hit the same wall and solved it with -lowercase base36 CIDs in subdomain gateways; that is the path if we ever need -it, and it is noted in `api/names.go` rather than half-built. - -### Decision 5 — the agent is the principal; the node is only the channel - -Traceability and granular policy are the product. "Agent `foo` may use -`inference://X`, agent `bar` may not" has to be a statement the *whole mesh* can -evaluate, and it has to survive an agent being paused on one node and resumed on -another. So agent identity travels with the agent, never with the host. - -A Biscuit is bound to a peer ID: `SamNode.Authorize` requires a `node()` -fact matching the connecting peer and enforces `BaselineReplayCheck` -(`client_peer_id == connection_peer_id`). That binding is right — for the -*channel*. It is the wrong place to express *who is asking*. - -The mesh already knows how to take in a foreign identity, and agents reuse that -pattern one level down. A node presents an OIDC JWT **once**, at enrollment; the -control plane verifies it against a configured issuer and mints a Biscuit; -`translateClaimsToFacts` turns claims into facts, and the JWT never appears on -the datapath again. The two domains are joined at exactly one point and never -mixed: - -| | asserted by | verified against | when | on the datapath as | -|---|---|---|---|---| -| node identity | control plane | the OIDC issuer | enrollment | a Biscuit | -| agent identity | the enrolled SAM component that admitted the agent | the platform's workload issuer (K8s SA token, pod certificate, SVID) | admission | an appended Biscuit block | - -The agent's facts ride as an **attenuation block appended to the node token**, -not as a second credential. `biscuit-go` cannot verify a block signed by a third -party (§8.2), so splitting them buys no extra cryptographic attribution while -costing a second verification on every request. The identifier is still the -agent's and still portable: teleport the actor to another worker and the SAM -component there verifies the same workload credential and asserts the same -`agent:` name. Nothing downstream notices the move. - -The residual trust, stated plainly: an enrolled node asserts its own agents. -Mesh policy limits this with a role's `allowed_agents`, which lists the -namespaces a node is allowed to use. Only a node whose role grants `*.c1.acme` -can claim an agent in that namespace, and a node with no grant cannot name any -agent at all. Non-repudiation *by the agent itself* needs proof of possession, -which stays an upgrade path (§8.6). - -## 4. Where the gateway lives: `sam-box`, without a libp2p host - -There are two binaries with disjoint jobs. - -**`sam-node` — the mesh member.** It owns the libp2p host, enrollment, identity, -discovery and the sidecar API (`/mcp`, `/v1/*`, `/sam/service/*`, -`/sam//…`) over TCP and its Unix socket. - -**`sam-box` — the sandbox dataplane.** One per sandbox. It holds **no libp2p -host, no enrollment, no mesh identity**. It serves the named-tunnel boundary -(CONNECT, connect-udp) on the sandbox-facing socket, enforces the egress -policy, does MITM/secret injection where configured, and reaches the mesh -solely by dialling the local `sam-node` sidecar Unix socket as a client. - -What this buys: - -* *N* sandboxes per host still share **one** libp2p host, so no *N*× DHT - clients, identify/ping loops or router connections — the thing that would - otherwise cap density in the scale experiment. -* `sam-box` becomes small and boring: an HTTP CONNECT server plus an HTTP - client. No `join`, no OIDC, no Biscuit handling, no bbolt store. Most of - [cmd/sam-box/main.go](cmd/sam-box/main.go) is deleted. -* The blast radius of a compromised sandbox stops at its `sam-box`, which holds - one agent credential and can be killed and reissued in isolation. -* Failure isolation and lifecycle match the sandbox's, not the host's. - -`sam-box` is a **multiplexer of agents**: it may serve one agent per socket, or -many agents over one socket, and each flow it forwards carries that agent's own -identity (Decision 5, §8). Losing the libp2p host costs it nothing here — the -agent's authority never came from the host in the first place. - -`internal/sambox` keeps the ephemeral CA and secret injection and gains the -CONNECT server; `internal/node` keeps everything mesh. - -## 5. Contracts - -### 5.1 What the harness sees - -```bash -OPENAI_BASE_URL=http://mesh.sam.alt/v1 # inference, provider chosen by policy -MCP_URL=http://mesh.sam.alt/mcp # tools: local catalog + remote services -``` - -No `HTTP_PROXY`. No CA bundle, unless a domain is explicitly configured for -secret injection. No mesh concepts, and no token of any kind. -`cmd/chaos-agent` already takes `--mcp-url` and `--inference-url`, so it needs -no change beyond the values it is given. - -A harness that wants one specific service instead of policy-chosen routing uses -its mesh name directly: `http://code-reviewer.mcp.sam.alt/`. - -### 5.2 Gateway dispatch - -Applied to the tunnel request's `(host, port)` — `host` is the name -`tun2connect` preserved, never an IP — in order: - -1. **`mesh.sam.alt`** (`api.IsMeshEntrypointHost`) → the gateway's own - agent-facing surface, served in process: `/v1/models`, - `/v1/chat/completions`, `/v1/completions`, and `/mcp`. Nothing else. Any - other path is refused with 403 and never reaches the node. - - **The agent never touches the node.** A `sam-node` sidecar is a local, - operator-facing API: it can register services under the node's identity, and - its `/sam///` proxy will carry a request to any peer and - service the caller names. Worse, arriving on its Unix socket *is* the - credential — `withAuth` treats reaching the socket as proof of authorization, - on the grounds that it is the same bar as reading the token file. So piping - an agent's bytes to that socket would hand every sandbox the node's full - local authority. `sam-box` is the node's consumer; the agent is the mesh's - consumer, through `sam-box`, and the two are different surfaces. - - Discovery is deliberately absent from the list even though agents need it: - MCP already exposes `find_remote_tools` and `discover_remote_services` as - tools, so opening `/sam/service/discover` as well would widen the surface - without adding a capability. -2. **`..sam.alt`** (`api.ParseMeshHost`) → `sam-box` terminates - HTTP and rewrites onto the sidecar's existing surface: - `GET /sam/service/discover?type=&name=` picks a provider, then - the request is reverse-proxied to `/sam////`. No - new sidecar endpoint, no libp2p in `sam-box`. -3. **anything else** → egress policy (§5.3). Allowed flows are dialled directly; - flows on a secret-injection domain are terminated with the ephemeral CA. -4. **no match, or a name in the zone that resolves to nothing** → `403` with - `Boundary-Reason: not allowed by policy`. - -Case 2 is the only one that inspects payload, and only because provider -selection is an HTTP-level decision. `https://` to a mesh name therefore needs -the ephemeral CA trusted in the guest; v1 documents mesh names as `http://` -(the transport underneath is libp2p, already authenticated and encrypted) and -leaves guest CA installation to sandbox image build rather than a bootstrap -endpoint. - -### 5.3 Egress policy - -Deny-by-default, evaluated on the name, expressed in the node config next to the -existing `attenuation` block: - -```yaml -version: v1 -sandbox: - egress: - allow: - - "api.github.com" - - "*.pypi.org" - secrets: - "api.github.com": - kind: bearer - value_from: /etc/sam/secrets/github -``` - -Filtering by name without MITM is honest here: the gateway dials the name it -authorized and, for TLS, requires the client's SNI to match it. - -### 5.4 What a sandbox must provide - -`nano-init` builds the route out of a sandbox. It does not build the sandbox, -so a profile is the answer to "who provides the isolation", and there are three -supported answers. Each must satisfy the same five preconditions: - -1. **A network namespace with no way out.** No interface but loopback. This is - the one that cannot be compromised: an agent with a second route does not - have a boundary, it has a suggestion. -2. **A private `/etc/resolv.conf`.** `nano-init` points it at the resolver on - `tun0`, so anything sharing that file would have its DNS repointed too. -3. **A Unix socket to the boundary.** Sockets are filesystem objects and are - unaffected by the network namespace, which is what lets the sandbox have no - network and still be reachable. -4. **A way to create a TUN device.** A kernel with `CONFIG_TUN` in a microVM; - `/dev/net/tun` plus `CAP_NET_ADMIN` elsewhere. -5. **PID 1**, so children are reaped and signals and exit codes travel. - -| | `docker run --network none` | Firecracker microVM | Kubernetes pod | -| --- | --- | --- | --- | -| netns with no way out | the container runtime | the guest kernel, given no network device | **nothing does** | -| private `resolv.conf` | the container's mount namespace | the guest rootfs | **nothing does** | -| boundary socket | bind-mounted UDS | vsock, surfacing on the host as `_` | shared `emptyDir` | -| TUN | `--device /dev/net/tun --cap-add NET_ADMIN` | `CONFIG_TUN` in the guest kernel | `hostPath` of type `CharDevice` | -| PID 1 | the container's entrypoint | `/sbin/init` | the container's entrypoint | -| isolation strength | namespaces, shared kernel | own kernel | namespaces, shared kernel | - -The first two columns are satisfied by the runtime. The third is not, and the -reason is structural rather than an oversight: **every container in a pod shares -one network namespace**, so there is no per-container equivalent of -`--network none`, and the `resolv.conf` the kubelet generates is shared by all -of them. A pod profile therefore has to create those two namespaces for itself, -inside the container, before `nano-init` starts. - -The pod column needs no capabilities, which is worth stating because the obvious -guesses are both wrong. The device cgroup does not deny `/dev/net/tun`, so no -device plugin is involved and a bind mount is enough; and granting -`CAP_SYS_ADMIN` alone is actively worse than granting nothing, because the -namespace then gets created without the `CAP_NET_ADMIN` the tun needs. Creating -a user namespace first supplies both over the namespaces it owns, so that is the -path taken whenever user namespaces are available. What a pod does need is a -seccomp and AppArmor policy that permits `unshare(CLONE_NEWUSER|CLONE_NEWNS)` -and `mount` — Kubernetes applies neither profile by default, and Docker's -defaults block both. - -Because the failure is silent -- `tun0` alongside `eth0` is a working network, -not an error -- `nano-init` refuses to start in a namespace that has any -interface other than loopback and its own tun, whichever profile is in use. -That check is what makes a third profile safe to add rather than a way to -quietly lose the boundary. - -## 6. Test plan - -Coverage is pushed as far down the pyramid as it will go. Firecracker appears -exactly once, at the top. - -**Unit (`internal/…`, `api/`).** CONNECT and connect-udp request parsing, the -refusal-status mapping and the RFC 9297 capsule codec; the name↔URI -projection (`api/names_test.go`, already landed); dispatch classification -(`mesh.sam.alt` / mesh name / public / denied); the agent-facing path allowlist; -wildcard matching in the egress allowlist; SNI-vs-authorized-name mismatch. -Pure functions, microseconds. - -**Integration (`tests/integration/`, budget 10s each).** The whole datapath, no -containers: - -* node A registers a fake OpenAI-compatible backend — reuse the - `registerInferenceService` + `httptest` pattern already in - `openai_facade_test.go`; -* node B registers a fake MCP service — reuse `newFakeMCPHandler`; -* node C runs with a sidecar Unix socket, and a `sam-box` beside it serves - the boundary on a second socket in `t.TempDir()`; -* the test opens real CONNECT tunnels over that socket (stdlib `net/http`, the - same wire shape tun2connect produces) and asserts: - 1. `mesh.sam.alt:80` → `/v1/chat/completions` reaches A's fake LLM across the mesh, - 2. `mesh.sam.alt:80` → `/mcp` `call_remote_tool` reaches B's fake MCP, - 3. `.mcp.sam.alt:80` resolves through discovery to B and reaches it, - 4. an allowlisted public name reaches a local `httptest` server, - 5. a non-allowlisted name is refused with `403`, - 6. no path outside `/v1/*` and `/mcp` reaches node C at all. - -**E2E (`tests/e2e/*.bats`).** Two CUJs, no more: - -* *Container sandbox*: `docker run --network none` with the real harness plus - `nano-init`, a bind-mounted UDS to a host `sam-node`, against the existing bats - mesh (`lib/container_mesh.bash`). Asserts a real inference call, a real tool - call, and one blocked domain. -* *microVM sandbox*: the same rootfs under Firecracker, `skip` unless `/dev/kvm` - is present. It exists to prove the vsock↔UDS mapping and nothing else; every - behavioural assertion is already covered above. - -## 7. Dependencies - -Nothing on the host requires a `go.mod` change: - -* CONNECT / connect-udp **server** — a few hundred lines against the stdlib, - including the RFC 9297 capsule codec, on a Unix socket. -* CONNECT **client** (tests, `sam-bench`) — stdlib `net/http` request writing. -* AF_VSOCK — never touched by Go code (Decision 3). -* MITM CA and secret injection — `internal/sambox`, already ours. -* Mesh name projection — `api/names.go`, stdlib only. - -### 7.1 The guest-side datapath: tun2connect - -The guest side is Go, in `cmd/nano-init`, which is **its own module** -(`github.com/google/sam/cmd/nano-init`). Its datapath is the -[`tun2connect`](https://github.com/aojea/agents.net) library — gVisor's -netstack terminating the sandbox's TCP/IP in userspace, a `BoundaryClient` -speaking CONNECT and connect-udp, and a `VirtualDNS` preserving names — plus -`github.com/vishvananda/netlink` to build `tun0`. All of it lives in the -nano-init module, so the root `go.mod` is untouched and the host binaries -carry none of it. - -Consuming the datapath as a library, rather than shipping a universal guest -binary, is a deliberate split: the engine, the tunnel client and the name -preservation are universal and live upstream with their own tests; what stays -in `nano-init` is exactly what is SAM- and platform-specific — the vsock -boundary for microVMs, `--create-namespaces` for pods, the `copy` mode for -image builds, and PID 1 duty. - -Owning the guest datapath is what makes the rest of this design possible: - -* the destination stays a **name** all the way to the tunnel request. A stack - that proxies by address has already thrown away the thing policy reasons - about, so the virtual DNS hands out synthetic addresses from - `100.64.0.0/10` and `100::/64` and the engine restores the name when it - opens the flow — and refuses a flow that never had one; -* policy and telemetry decisions can be made *inside* the guest — per-process - attribution, for instance — which an external binary cannot be extended to do; -* it builds for whatever architectures the project builds for, with no - third-party release URL pinned into the image; -* it is covered by `go test`, not only by the bats CUJ. - -The module boundary is the point. A userspace network stack is a large -dependency, and keeping it in a separate module means it is a guest-image -concern rather than something every `sam-node` and `sam-box` build has to carry. - -The host is unaffected either way: the sandbox boundary is CONNECT over a byte -stream, so the guest implementation could be replaced without changing anything -on the host or rewriting any test above. - -## 8. Agent identity - -### 8.1 Requirements - -1. **Portable.** An agent paused on node A and resumed on node B is the same - principal. Identity moves with the agent's state, never with the host. -2. **Verifiable mesh-wide.** Any peer can decide "agent `foo` may use - `inference://X`, agent `bar` may not" without asking anyone. -3. **Scales to 10⁹ agents and services.** No central per-agent record, no - per-agent mint on the hot path, no enumeration in any policy document, no - revocation list proportional to the agent population. - -### 8.2 The library constraint that decides the shape - -`biscuit-go/v2 v2.2.0` has **no third-party blocks**: the library's own sample -suite explicitly skips `test024_third_party.bc`, and the API exposes no -public-key (`trusting`) checks. So an agent-signed block cannot be embedded -inside the node's token. - -What the library *does* give us is exactly enough: - -* `New` — mint under a root key; -* `CreateBlock` + `Append` — **offline attenuation**, no root key, no network; -* multi-root verification (`VerifyBiscuit(..., trustedPublicKeys, ...)`); -* `RevocationIds()` — per-block revocation identifiers; -* `Seal` — stop further attenuation. - -Hence the agent's facts travel as an ordinary appended block on the node token -(Decision 5). This is not a workaround — it is why **`Authorize` needs no change -at all**: appended blocks are already part of the token and already evaluated, -so only the component that admits the agent has to learn to append. If -`biscuit-go` later gains third-party blocks, the agent can sign its own block -and non-repudiation arrives without a wire break. - -> **Correction (falsified by test).** The paragraph above was wrong, and -> `TestAttenuationBlockFactsAreInvisibleToTheAuthorizer` in `internal/identity` -> now pins why. `Authorize()` merges **only the authority block's** facts and -> rules into the authorizer's world; every appended block is evaluated in a -> world of its own, so its facts can satisfy that block's own checks and -> nothing else. A fact appended by a holder is invisible to the far end's -> policy. -> -> That is deliberate and correct: if appending could add facts the authorizer -> sees, attenuation would be able to *grant* authority instead of only -> narrowing it, and any bearer could promote itself. -> -> The consequence is that "append a block naming the agent" cannot work, so the -> claim has to travel beside the token instead — and it is worth being precise -> that this costs nothing cryptographically. Whoever can append a block can -> append *any* block, so a node's claim about which agent it is speaking for is -> worth exactly what the node is worth either way (Decision 5's residual -> trust, bounded by binding namespaces to node attestation). Only a block -> signed by the agent itself would change that, and that needs third-party -> blocks. -> -> Options, to be settled before the node side is built: -> -> 1. **Propagate `X-Sam-Agent` peer to peer** and have the receiving node inject -> `agent(...)` into its authorizer, exactly as `injectIdentityFacts` already -> injects facts the node derived itself. Simple, and honest about the trust -> involved. -> 2. **Append the block anyway and read it back out** of the token at the far -> end, then inject the same fact. Identical trust, plus datalog text parsing, -> since `biscuit-go` exposes no structured per-block fact accessor. -> 3. **Wait for third-party blocks**, which would make the agent's own signature -> the thing being verified, and is the only option that adds real -> non-repudiation. -> -> **Decided: option 1**, implemented in `internal/node/agent.go`. - -### 8.2.1 What the agent claim covers, and what it does not - -**Covers.** Attribution and policy on **both** datapaths. A peer can authorize -and audit "agent `reviewer-7` called me" rather than only "some node did", using -the existing vocabulary, because the claim is injected as an ordinary `agent()` -fact. `TestAgentPolicyCUJ` shows two agents behind two gateways on one node -being told apart by the provider's policy — over HTTP for inference and over the -libp2p stream for tool calls — including a lookalike authority being refused and -an unidentified sandbox being refused. - -The two paths carry it differently because they authenticate differently: HTTP -requests carry `X-Sam-Agent`, and streams carry `AuthFrame.agent`. On the stream -path the claim is bound to the MCP *session* rather than the request, since the -SDK hands a tool handler the session's context and not the request's. That fits -how sandboxes work anyway: one gateway serves one agent, so a session belongs to -one agent for its whole life. - -**Does not cover.** - -* **Proof.** The claim is the calling node's word. A node that lies can name any - agent, so a mesh that cares must also constrain which peers may speak for - which agent namespaces. Carrying it in a header rather than a block costs - nothing here: an appended block is exactly as forgeable by the same party. -* **Anything that never leaves the node**, which the boundary already gates - locally. - -**A consequence to design around.** A node's own housekeeping carries no agent, -because no agent asked for it. A provider whose policy demands an agent -unconditionally therefore also refuses that node's model-catalog probe, and its -models stop appearing in peers' `/v1/models` listings even though agents can -still call them. This was found by the test, not by reasoning. Policy that means -to gate agent traffic should say so rather than demanding an agent everywhere. - -### 8.3 Why it scales: delegation, not enumeration - -Three rules, each of which removes a per-agent bottleneck: - -**Minting is offline and local.** The control plane never mints per agent. It -grants a *namespace holder* — in practice the enrolled `sam-node`/`sam-box` in a -cluster, scoped by its attestation (§8.6) — authority over an agent namespace, -once. That holder asserts a per-agent identity by appending a block to its own -token with `Append`: no root key, no round trip, no central state. Admitting an -agent is O(1) local work, which is the only way 10⁹ of them exist. - -**Policy is written over namespaces, not identities.** This needs almost no new -machinery, because the existing vocabulary is already shaped for it: -`BuildTargetDatalogFacts` compiles targets into `granted_target_prefix`, -`granted_target_suffix`, `granted_target_set` and `granted_target_exact`. Making -`agent` a first-class principal is: - -* `FactAgent = "agent"` in `api/datalog.go`; -* the agent block's `agent(...)` fact injected as a `target_fact` at - authorization time. - -After that, `allowed_targets: ["agent:acme/prod/*"]` works with **zero new -evaluation logic**, and "foo yes, bar no" is expressed as a namespace, a set or -a prefix — never as a list of a billion names. - -**Revocation is tiered.** A revocation list sized like the agent population is -not a design, so nothing depends on one: - -| what happened | mechanism | cost | -|---|---|---| -| agent is done or misbehaving | kill the sandbox | instant, local, free | -| credential must stop being usable elsewhere | short-lived workload credential; the platform stops renewing it | bounded window, no list | -| a namespace holder is compromised | `RevocationIds()` on its delegation block | one entry kills every agent under it | - -### 8.4 How the identity travels - -The agent's credential bundle lives **in the agent's own state**, next to -whatever else the platform migrates — not in `sam-box`'s configuration and not in -the node's store. Pause, snapshot, move, resume: `sam-box` on node B reads the -bundle from the migrated state and starts asserting it. **No control-plane call -on resume.** That is what makes migration cheap, and it is the same property -that makes 10⁹ agents possible. - -### 8.5 Where the agent's identity comes from - -Not from the agent. The harness stays unmodified (Goal 1), so it never asserts -anything about itself — it could only lie. It comes from the credential the -platform **already** issues to the workload: - -* a **Kubernetes projected service-account token**, audience-scoped and - short-lived; -* a **pod certificate** (KEP-4317) — which Substrate already provides today via - its `podcertcontroller` polyfill; -* a **SPIFFE SVID**, where SPIRE is in play. - -`sam-box` verifies that credential against the platform's issuer at admission — -in-cluster, cheap, no mesh round trip — and translates it into `agent:` facts by -the rules in §8.8. **The scheduler needs no mesh enrollment and no mesh -credential of its own.** It keeps doing exactly what it already does for every -workload: project an identity document. This is the OIDC relationship, not a -merger of the two domains. -The check that carries the weight is not the signature but the subject: the -credential must attest the `external_id` the bundle declares. Every sandbox on a -platform holds a valid credential, so verifying the signature alone would let -any of them claim to be any other. And the issuer is operator configuration -(`--credential-issuer`), never a bundle field — the bundle travels with the -agent, so an issuer named there could be one an attacker controls, and their -self-signed credential would verify perfectly. - -Verification is the default: a bundle without an issuer to check it against is -refused. Deployments with no platform issuer can still run, but by saying so -(`--insecure-unverified-bundle`), because a name the whole mesh authorizes -against should not become trustworthy through a field being left unset. -`sam-box` then binds that identity to the channel, so nothing has to be asserted -in-band afterwards: - -1. **One socket per agent (default).** Identity is a property of the socket, - fixed when the sandbox is created: one vsock UDS per microVM, one bind-mounted - socket per container. Nothing in-band, nothing to spoof. -2. **Proxy-Authorization (multiplexing).** When one `sam-box` fronts many agents - over one socket, Basic credentials on the tunnel request carry the agent id - and its admission token. It is standard, in-band, supported by every HTTP - proxy client, and it makes identity **per-connection** — which is exactly - what a multiplexer needs. - -### 8.6 The honest limit - -An enrolled node asserts its own agents, so a compromised node can claim any -agent identity inside the namespaces its role allows (`allowed_agents`, §3). -That limit is the mitigation, and the same policy machinery enforces it. Without -it, one compromised node could impersonate any agent anywhere. A node that holds -no grant cannot name any agent. - -The grant is keyed on the node's **role**, not on its labels. Nodes declare -their own labels at OIDC enrollment and the control plane only checks the -syntax, so a namespace derived from a label would be one the node picked for -itself. Roles are resolved from verified OIDC claims against bindings the admin -wrote, so that chain holds all the way back. - -Two things follow: - -* How tight the limit is depends on the connector's naming (§8.8, property 5). - If a name has no label for the boundary it runs in, the only grant you can - write is a tenant-wide one. -* `allowed_agents: ["*"]` goes back to the old, unlimited behaviour. It is - allowed because some meshes have a single tenant, and nodes log a warning so - it is never silent. - -Removing the residue entirely requires proof of possession: the agent holds a -key and signs a per-request binding. That needs either a mesh-aware harness -(violates Goal 1) or a signer inside the sandbox, and it needs third-party -blocks in `biscuit-go` to be attributable. It is a clean upgrade, not a rewrite. - -### 8.7 Consequences for the plan - -* `api/` — `FactAgent`, the §8.8 translation helpers, and - the §10 bundle schema. -* `internal/node` — **nothing** for authorization: appended blocks are already - verified and evaluated. Only the namespace-binding policy is new. -* `internal/sambox` — holds the bundle, attaches it per flow, per socket or per - tunnel connection; serves the §10 control socket and routes the §9 serving - channel. -* `cmd/nano-init` — owns the guest side of the ingress channel (§9.2). -* Sidecar — propagates the agent header on egress alongside `X-Sam-Biscuit`. -* Tests — the CUJ that demonstrates the selling point belongs at integration - level: two agents behind one `sam-box`, same node, same mesh; `foo` reaches - `inference://X` and `bar` is refused, purely on their own credentials. - -### 8.8 The agent identifier - -**Decided: dot-separated, DNS-shaped, most-specific-first.** -`agent:reviewer-7.prod.acme.example`, matched by `agent:*.prod.acme.example`. - -The policy engine chooses this for us. `BuildTargetDatalogFact` compiles -`*.acme.example` into a suffix fact that **keeps the leading dot** -(`.acme.example`) and `acme.*` into a prefix fact that **keeps the trailing dot** -(`acme.`). Wildcards are therefore already anchored on dot boundaries: -`evil-acme.example` cannot match `*.acme.example`. A slash-separated -`agent:acme/prod/name` would need new fact types and new matching code, and -would reintroduce the boundary bug the dot anchoring already avoids. It also -matches every other name in the system: service names are validated by -`dnsNameRegex`, and mesh hosts are DNS-shaped (§Decision 4). - -**Is it a mesh-only convention? Yes — and that is fine, for exactly the reason -you gave.** It is the same relationship the mesh already has with OIDC: -`translateClaimsToFacts` turns `sub`, `email` and `groups` into `user`, `email` -and `group` facts, and policy is written against *those*, never against the raw -JWT. Third parties do not adopt `agent:`; connectors translate into it. - -A connector's translation must satisfy four properties, and this is the part -worth writing down because "without losing capabilities" lives here: - -1. **Total** — every foreign identity maps to exactly one mesh identifier. -2. **Injective** — two foreign identities never collide, or policy leaks across - tenants. The rightmost labels must be an authority the platform actually - controls. -3. **Hierarchy-preserving** — foreign hierarchy boundaries must land on dot - boundaries. This is the capability-preserving requirement: if the connector - flattens the hierarchy, wildcard policies stop being expressible and the - operator is forced back to enumeration. -4. **Auditable** — the original identifier travels verbatim alongside the - translated one. Where translation cannot round-trip, the verbatim value is - what an auditor reads. -5. **Bounded** — the name must include a label for the boundary where the - agent's workload credential stops being valid, usually the cluster or trust - domain. Node grants (`allowed_agents`, §8.6) are written against that label. - A mapping can satisfy properties 1–4 and still fail this one: a flat - `spiffe://acme.example/prod/reviewer-7` → `reviewer-7.prod.acme.example` has - no cluster label, so every node hosting these agents needs a tenant-wide - grant and the limit gets much weaker. The Kubernetes and Substrate rows - below keep the cluster in the name, so their grants stay tight. - -| foreign identity | mesh identifier | -|---|---| -| `spiffe://acme.example/prod/reviewer-7` | `agent:reviewer-7.prod.acme.example` | -| K8s SA `prod/reviewer` in cluster `c1.acme.example` | `agent:reviewer.prod.c1.acme.example` | -| OIDC `iss=https://acme.example`, `sub=reviewer-7` | `agent:reviewer-7.acme.example` | - -Each segment must be a valid DNS label; a connector encodes anything else. -Non-hierarchical attributes do **not** belong in the name — that is what the -attested label machinery (`api/labels.go`) is for. - -## 9. Ingress: agents that serve - -An agent serves exactly one thing: itself, over A2A. `code-reviewer.a2a.sam.alt` -is already the name (Decision 4); serving it just means being its provider. -MCP and inference services are operator services, declared in a node's -configuration — an agent is not a generic backend and never advertises one. - -### 9.1 Declaring: a static contract, no runtime registration - -The agent does **not** declare anything, to anyone, ever. There is no -registration API on the node — services only exist by declaration in the -node's configuration at startup — and there is no announce endpoint on the -gateway. Both facts follow from the same rule: anything an agent can request -at runtime is an interface it can abuse. A runtime declaration carries a -*name*, so an agent could advertise itself as `code-reviewer` under the -node's identity and take over somebody else's traffic; give it a -`target_url` and it can point the mesh at anything and turn its node into an -open relay. - -So the whole route is contracted before the agent runs: - -* **The bundle names what the agent serves** (§10.1 `serves`): one name, one - port. `sam-box` routes that name to that port inside the sandbox from - startup, over the reverse channel (§9.2). A name outside the contract is - not expressible rather than merely rejected. -* **The node's configuration declares the service** — `type: a2a`, the - contracted name, `target_url` pointing at the gateway's serving listener. -* **Advertisement is gated on the probe.** The node fetches the agent's card - (`/.well-known/agent-card.json`) through the gateway before advertising, - and keeps re-probing. An agent that is not up yet, or has died, is simply - not discoverable; when it answers, it is. Readiness is observed, never - asserted. - -The agent runs a stock A2A server on its contracted port and knows nothing -else. Its card can claim any address it likes — the mesh regenerates the -card at the consumer edge (interfaces rewritten to the mesh path, stale -signatures dropped), so nothing the agent writes in its own card escapes the -sandbox unverified. - -**Implemented, including delivery into an isolated sandbox.** Reaching back in -cannot be done by dialling the agent: every sandbox has a network namespace of -its own, so the gateway's `127.0.0.1` is its own loopback and not the agent's. -That is true of all three profiles, because it follows from the isolation -rather than from any one runtime. - -The way in is the way out. Egress crosses the boundary over a pathname Unix -socket, which network namespaces do not apply to because it is a filesystem -object. Ingress uses a second one: `nano-init --ingress-socket` serves it from -inside the sandbox, where it can reach the agent at the contracted port, and -`sam-box --agent-ingress-socket` dials it. The handshake is Firecracker's — -`CONNECT `, then `OK` — so a microVM can offer the same protocol over -vsock without the gateway learning the difference. - -`TestSandboxServesThroughTheReverseChannel` drives a real sandbox and asserts -both halves: that dialling the agent directly from outside fails, and that the -same agent answers through the channel. `TestAgentIngressCUJ` covers the -mesh-facing half with a stock A2A SDK on both ends: the card is regenerated at -the consumer, the message round-trips, and the agent has no serving surface to -ask anything of. - -### 9.1.1 Why this is also the better UX - -The runtime-announcement design this replaces argued that only the agent -knows *when* it is ready and *which port* it chose. Both turned out to be -better served without an API: - -* **Readiness is the probe's job.** A declared-but-dead service is registered - yet withheld from discovery until its card answers. That closes the - advertise-before-listening window without a health-check protocol — and - without trusting the agent's claim, which the old design listed as an open - problem. -* **The port is part of the contract.** An agent that must be told nothing - can still be told one thing by its environment (every serverless platform - does exactly this); in exchange, port flapping and re-registration rate - limits stop being problems because re-registration does not exist. -* **Lifetime is tied to the sandbox, structurally.** The gateway's serving - listener closes when the sandbox goes; the probe fails; discovery - converges. Nothing unregisters because nothing registered. -* **Resume is still automatic.** The bundle travels with the agent (§8.4), so - `Attach` on another host renders the same contract: the new gateway routes - the same name, the new node declares the same service, and the probe - re-points discovery to the new peer. The property §8 exists to provide - survives — it never needed a runtime API, only a declaration that moves - with the agent. - -### 9.2 The reverse channel - -Inbound is the one place the datapath is not egress-shaped: - -```text -remote peer → libp2p → sam-node → sam-box ingress endpoint - │ - ▼ one stream per inbound request - sandbox boundary - │ - nano-init accept loop - │ - 127.0.0.1: (the agent's listener) -``` - -So a sandbox has exactly two channels, one per direction: - -| channel | direction | protocol | who listens | -|---|---|---|---| -| egress | guest → host | named HTTP tunnels (CONNECT, connect-udp) | host (`sam-box`) | -| ingress | host → guest | one stream per request (`CONNECT `) | guest (`nano-init`) | - -Firecracker's vsock is bidirectional, so host→guest needs no new transport: the -host connects to the firecracker UDS and writes `CONNECT `, with the guest -listening on that vsock port. A container gets the symmetric arrangement with a -second bind-mounted socket. This is also what finally justifies `nano-init` -beyond PID 1 hygiene: it owns the guest side of the ingress channel. - -### 9.3 Authorization closes the loop - -Nothing new is needed. `sam-node` authorizes the **caller** — their node token -carrying their agent block (Decision 5) — before the stream ever reaches -`sam-box`. "Agent `foo` may call agent `bar`" is therefore one policy statement, -evaluated at `bar`'s node, with the existing machinery, and both ends are named -by portable identities rather than by hosts. - -### 9.4 Migration - -The serving contract is part of what travels (§8.4). On resume, `sam-box` on -node B routes the same contracted name, node B's configuration declares the -same service, and the probe re-points discovery to node B. The name is -stable because it belongs to the agent, not to the node — which is the whole -point of §8. - -## 10. The platform integration interface - -This is the seam a scheduler or harness connector builds against. It must be -small, versioned, and the only place identity enters the system. - -### 10.1 The agent bundle - -What the platform provides per agent. It lives in the agent's state directory, so -it migrates with the agent by construction: - -```yaml -version: v1 -agent: - id: reviewer-7.prod.acme.example # canonical mesh identifier (§8.8) - external_id: spiffe://acme.example/prod/reviewer-7 # verbatim, for audit - credential: /var/run/secrets/substrate/token # the platform's own workload - # credential, verified at - # admission (§8.5) -egress: - allow: ["api.github.com", "*.pypi.org"] - secrets: - "api.github.com": {kind: bearer, value_from: /etc/sam/secrets/github} -serves: - name: code-reviewer # the agent's A2A name; routed to its contracted port - port: 8080 -``` - -### 10.2 Operations - -Exposed by `sam-box` on a control socket that is **not** reachable from inside -the sandbox: - -| operation | meaning | -|---|---| -| `Attach(bundle)` | admit an agent; returns the egress and ingress endpoints to wire into the sandbox | -| `Detach(agent_id)` | stop it: close the serving listener and channels, drop credentials | -| `Refresh(agent_id, credential)` | hand in a rotated workload credential (§8.3) | -| `Status(agent_id)` | what is routed and connected, for the scheduler's reconcile loop | - -### 10.3 Guarantees the interface owes a connector - -1. **`Attach` is idempotent, keyed by agent id.** Resume after a crash or a - migration is just another `Attach` — no special-case path. -2. **Identity never arrives in-band from the sandbox.** The platform is the sole - identity source (§8.5). -3. **The agent id is stable across `Attach` on different hosts.** This is what - makes migration invisible to the rest of the mesh. -4. **`Detach` is complete.** No residual advertisement; discovery converges. -5. **Versioned.** `version: v1`, and the schema lives in `api/`, so it is a real - contract and not a convention. - -### 10.4 The first connector: Agent Substrate - -[Agent Substrate](https://github.com/agent-substrate/substrate) is the -integration target, and it lines up unusually well: - -| Substrate | SAM | -|---|---| -| **Actor** (the agent) | the principal: `agent:..` | -| **Atespace** (namespace) | the hierarchy label policy wildcards on | -| routing by Host `my-counter-1.demo.actors.resources.substrate.ate.dev` | already dot-separated and most-specific-first — the same shape as §8.8 | -| `podcertcontroller` (KEP-4317 pod certificates), projected SA tokens | the workload credential `sam-box` verifies at admission (§8.5) | -| `atelet` per-node DaemonSet; `ateom-gvisor` / `ateom-microvm` | where `sam-box` attaches: one sandbox, two channels | -| suspend/resume with full-state snapshot (RAM + filesystem) | carries the agent bundle with no extra work (§8.4) | -| `atenet` (DNS, Envoy routing, proxy sidecars, CONNECT) | the seam to negotiate: who owns egress | - -The name mapping is a pure string translation with no capability loss, which is -the §8.8 test: the Host `my-counter-1.demo.actors.resources.substrate.ate.dev` -becomes `agent:my-counter-1.demo.actors.resources.substrate.ate.dev`, and -`agent:*.demo.actors.resources.substrate.ate.dev` means "every actor in atespace -`demo`" — injective, hierarchy-preserving, reversible. - -Two things to settle **with** the Substrate side rather than assume: - -* **Egress ownership.** `atenet` already supplies DNS, routing, proxy sidecars - and CONNECT. The named-tunnel boundary has to slot in as the sandbox's egress - rather than compete with it — speaking CONNECT is what makes that a fit - rather than a translation. `sam-box` behind atenet's sidecar seam is the - likely arrangement, but that is a conversation, not a decision to take - unilaterally. -* **Ingress.** Substrate already routes inbound to actors by Host header. §9 - must reuse that path rather than build a parallel one: a SAM ingress - declaration should end up as a Substrate route. - -### 10.5 Still to settle - -* **Wire format — decided.** Proto in `api/sam.proto` for the operations - (`AgentAttach`/`AgentDetach`/`AgentRefresh`/`AgentStatus` request and response - messages), YAML for the bundle. Substrate is Go, so proto costs its connector - nothing; the bundle stays YAML because its canonical copy is a file in the - agent's state directory, where it has to be readable and diffable by whoever - operates the platform. `AgentBundle` in the proto is the transport mirror of - that file, not a second source of truth. -* **Bulk pre-creation is deliberately not in v1** — premature until the semantics - are proven. What v1 owes it is *room*: `Attach` takes a bundle and is - idempotent per agent id, so `AttachBatch` is a purely additive operation - later, and the offline attenuation in §8.3 already makes it possible with no - protocol change. - -## 11. Remaining smaller open items - -1. **UDP — shipped.** Carried as connect-udp (RFC 9298) with capsules on the - same one-socket boundary, under the same name-based deny-by-default policy. - DNS never leaves the guest either way: port 53 is answered by the virtual - DNS, and everything else is a named, policy-checked session. -2. **Guest trust of the ephemeral CA.** Baked into the sandbox image at build - time, or re-introduce a bootstrap endpoint? v1 avoids the question by keeping - mesh names on `http://` (§5.2). -3. **Peer-pinned mesh names.** Deferred until a DNS-safe peer encoding is chosen - (Decision 4). diff --git a/site/content/docs/concepts/_index.md b/site/content/docs/concepts/_index.md new file mode 100644 index 00000000..84739f83 --- /dev/null +++ b/site/content/docs/concepts/_index.md @@ -0,0 +1,9 @@ +--- +title: "Concepts" +linkTitle: "Concepts" +weight: 2 +--- + +How SAM works, in enough depth to reason about a deployment. Read +[Architecture](architecture/) first. The other pages each take one part of +it further. diff --git a/site/content/docs/concepts/architecture.md b/site/content/docs/concepts/architecture.md new file mode 100644 index 00000000..d7415846 --- /dev/null +++ b/site/content/docs/concepts/architecture.md @@ -0,0 +1,157 @@ +--- +title: "Architecture" +linkTitle: "Architecture" +weight: 1 +--- + +An agent mesh has a small control plane, one or more routers, and any number +of nodes. The control plane decides who is in the mesh and what they may do. +Routers make the nodes reachable. Nodes do the work: they publish services +and call each other's services on behalf of the agents next to them. + +```mermaid +flowchart LR + subgraph cp["control plane"] + CP["sam-control-plane
identity · policy · signing key"] + DB[("database")] + CP --- DB + end + IDP["identity provider
(OIDC)"] + R["sam-router
bootstrap · relay · DHT"] + A["sam-node A"] + B["sam-node B"] + AG["agent"] + SVC["your tool or model"] + + CP -. verifies tokens .-> IDP + R -- lease --> CP + A -- enroll / refresh --> CP + B -- enroll / refresh --> CP + A <--> R + B <--> R + A <-. direct when possible .-> B + AG -- "MCP · OpenAI API
(local)" --> A + B --- SVC +``` + +## The control plane + +`sam-control-plane` is an HTTP service backed by SQLite or PostgreSQL. It is +the only component that has to be trusted. It is not on the data path: tool +calls and model requests never pass through it. + +It has four jobs: + +- **Admitting nodes.** A node presents an OpenID Connect token from the + configured identity provider, or a bootstrap token minted by an operator, + together with proof that it holds the private key it is registering. +- **Issuing credentials.** For each admitted node, the control plane mints a + [Biscuit](https://www.biscuitsec.org/): a signed token that carries the + node's peer ID, its roles, the identity facts from the login (user, email, + groups), the labels it was allowed to declare, and the grants its roles + give it. Nodes verify these tokens offline with the control plane's public + key. +- **Holding the mesh policy.** Roles and bindings are edited through the + admin API or the console, and nodes read them on a schedule. +- **Tracking routers.** Routers hold short leases. A new node fetches the + list of live routers from `/info` before it connects to anything. + +The control plane also rotates its signing key, keeps a ban list, and serves +the public keys that nodes need to verify their peers. [Identity](../identity/) +covers all of that. + +## Routers + +`sam-router` is a libp2p peer with a stable address. It runs the mesh's +Kademlia DHT (a distributed hash table), where nodes advertise their services +and look up who provides what. It also relays traffic for nodes that cannot +accept inbound +connections. A router has no policy of its own. It verifies every peer's +credential and refuses peers it cannot verify and peers that have been +banned. + +Routers keep no state apart from their key file, which fixes their peer ID. +You can run any number of them. Nodes learn about them from the control plane +and connect to the ones that answer. + +## Nodes + +`sam-node` runs as one process per host or per pod, next to the service it +serves or the agent that uses it. On the mesh side it is a libp2p peer with +its own Ed25519 key. Its peer ID is derived from that key, and every other +component refers to the node by that ID. + +Towards the mesh, a node: + +- publishes the services declared in its configuration file (MCP servers, + OpenAI-compatible inference backends, A2A agents) to the DHT and, on + request, to the control plane's catalog +- accepts connections from other nodes, verifies their credential, evaluates + policy for the requested service, and proxies the request to the local + backend if the check passes +- verifies the credential of every node it calls, so that a caller can + require properties of a provider (its labels, for example) before sending + any data + +Towards the agent, a node exposes a local API on `127.0.0.1:8080` by default +and on a Unix socket. The API includes an MCP server whose tools discover and +call services across the mesh, an OpenAI-compatible `/v1` endpoint that +routes model requests to inference providers on the mesh, and a proxy path +`/sam////` that reaches a specific service directly. +[Networking](../networking/) describes the API and how traffic moves. + +## Names instead of addresses + +A service is identified by type and name: `mcp://code-reviewer`, +`inference://vllm-eu`, `a2a://triage`. Policy grants are written against +these names, discovery returns them, and the node that hosts a service +decides whether a caller may use it. IP addresses do not appear in policy. +Because of this, a node can move between networks or sit behind NAT without +any rule changing. + +## Where decisions are made + +| Decision | Made by | Based on | +|---|---|---| +| May this identity join, with this role and these labels? | control plane, at enrollment | OIDC token or bootstrap token, mesh policy bindings, `allowed_labels` | +| Is this peer's credential genuine and current? | every node and router, on every connection | control plane public keys, expiration, ban list | +| May this caller use this service? | the node hosting the service | grants in the caller's credential, the synced mesh policy, the host's own local rules | +| Is this provider acceptable to me? | the calling node, before sending data | the provider's credential and attested labels | + +Every one of these decisions is deny by default. A new control plane with +no policy admits nodes but grants them nothing, and a node with no services +configured publishes nothing. [Authorization](../authorization/) explains +the policy model. + +## Trust boundaries + +The control plane is the trust root. Whoever controls it controls who is in +the mesh and what the policy says. A node refuses to fetch its trust root +over plaintext from a remote address, so a control plane URL must use +`https://` unless the control plane runs on the same host. + +Routers see connection metadata and relay encrypted streams. They cannot +read relayed traffic and cannot mint credentials. A compromised router can +deny service but cannot grant access. + +Nodes trust each other only as far as a verified credential says. The node +that hosts a service has the final say over who calls it, even beyond what +the control plane granted: its local configuration can add conditions that +the caller's credential must also satisfy. + +## Packaging + +The same three programs ship in several forms: + +- **Binaries** for Linux, macOS and Windows, from the releases page or the + install script. +- **Container images**: `ghcr.io/google/sam-control-plane`, + `sam-router`, `sam-node`, `sam-console` and `sam-one`. +- **`sam-one`**, a single binary that runs the control plane, a router and + the console in one process on one port, for laptops, Cloud Run and small + meshes. +- **Helm charts**: `sam-mesh` deploys the control plane, router, console and + PostgreSQL. `sam-node` deploys a node beside a service container. + +The web console (`sam-console`) is a separate program that talks to the +control plane's admin API. It is not required to run a mesh. diff --git a/site/content/docs/concepts/authorization.md b/site/content/docs/concepts/authorization.md new file mode 100644 index 00000000..3094f740 --- /dev/null +++ b/site/content/docs/concepts/authorization.md @@ -0,0 +1,212 @@ +--- +title: "Authorization" +linkTitle: "Authorization" +weight: 3 +--- + +Authorization in SAM answers one question: may this caller use this service +on this node? Three sources contribute to the answer, and the node that hosts +the service combines them: the caller's credential, the mesh policy, and the +node's own configuration. Each source can only narrow what the others allow. +If none of them grants access, the answer is no. + +## Services are the unit of authorization + +A node publishes services, each with a type and a name: `mcp://calculator`, +`inference://vllm-eu`, `a2a://triage`, and the built-in +`system://sam.catalog` that answers discovery queries. Policy grants access +to services by these names. It does not look inside a service: a grant on +`mcp://db` offers every tool that the MCP server exposes. To offer different +privilege levels, publish different services (`mcp://db-reader`, +`mcp://db-writer`) and grant them separately. Choosing which tools a backend +exposes is the job of the backend, or of a small MCP server placed in front +of it. + +## Mesh policy: roles and bindings + +The mesh policy is a document held by the control plane. You edit it through +`POST /policies` or the console. `sam-one` can also seed it on first boot +from a file (`--policy-file`). The document has two lists. + +**Roles** name a set of permissions: + +```json +{ + "name": "developer", + "allowed_services": ["mcp://code-reviewer", "mcp://build-runner.*", "inference://*"], + "allowed_targets": ["group:dev-nodes"], + "allowed_labels": ["region=*"], + "allowed_agents": [], + "custom_datalog": [] +} +``` + +- `allowed_services`: the services holders may call, as `type://name`. `*` + can stand for the whole name, for a leading component (`mcp://*.internal`) + or for a trailing one (`mcp://build-runner.*`). A bare `*` means every + service. +- `allowed_targets`: the nodes holders may call, as facts about the + destination node's identity: `node:`, `user:`, + `email:`, `group:`, `idp_role:`, or `*`. If absent, any + node may be called. +- `allowed_labels`: the labels a node with this role may declare at + enrollment: `key=value`, `key=*` or `*`. If absent, the node may declare + no labels. +- `allowed_agents`: the agent identifiers a node with this role may claim to + act for. Only used by the sandboxed-agent + [preview](../../preview/sandboxed-agents/). +- `custom_datalog`: extra Datalog facts or rules for holders of the role. + +**Bindings** attach roles to identities: + +```json +{ "role": "developer", "members": ["group:eng", "email:alice@example.com"] } +``` + +A member is one of: `user:`, `email:`, `group:` or `idp_role:` followed by a +value from the identity's OIDC claims; `node:` followed by a peer ID; or the +special value `sam:system:authenticated`, which matches every identity that +the identity provider authenticates. Be careful with the last one. On a +public identity provider it means everyone, so bind it only to roles with +few grants. + +Roles are never members and never claims. `role:x` is not a valid member, +and an identity provider cannot give out a mesh role by putting it in a +`roles` claim. Such a claim becomes an `idp_role` fact, and a binding can +choose to honour it. The three built-in roles, `sam:role:node`, +`sam:role:router` and `sam:role:sambox`, follow the same rule: a binary can +only enroll if a binding gives its identity the role it needs. + +The control plane validates a policy when it is posted. It rejects a policy +that references an undefined role, uses an unknown member prefix, or would +give a single identity more grants than the Biscuit authorizer can +evaluate. + +## From policy to facts + +At enrollment and at every refresh, the control plane resolves the identity's +roles from the bindings and writes the result into the credential as Datalog +facts: one `role(...)` fact per role, plus the grants compiled from the +lists of every role. For example, `allowed_services: ["mcp://calculator"]` +becomes `granted_service_exact("mcp", "calculator")`, `mcp://*` becomes +`granted_service_all("mcp")`, and `mcp://*.internal` becomes +`granted_service_suffix("mcp", ".internal")`. Wildcards keep their dot, so +`*.acme.example` matches `svc.acme.example` but not `evil-acme.example`. + +Nodes also fetch the policy themselves, every `--policy-sync-interval` (one +hour by default), and compile it into rules such as +`role("developer") <- group("eng")` and +`granted_service_exact("mcp", "calculator") <- role("developer")`. When a +node verifies a credential, these rules run against the identity facts in it. +A grant added to the policy therefore reaches every node within the sync +interval, with no need to reissue credentials. Removing a grant takes effect +through the credential instead: the facts already in a token stay valid until +the token is refreshed, which happens within its TTL (24 hours by default). + +## What the hosting node checks + +When a request for service `S` arrives from peer `P`, the node builds a +Biscuit authorizer and adds the following, in this order: + +1. **The request**: `service("mcp", "calculator")` for the requested + service, and `connection_peer_id(P)` from the authenticated connection. + If the caller named an agent, the agent claim is added together with the + check that the caller's own token grants that agent namespace. +2. **The baseline checks**: `client_peer_id($id), connection_peer_id($id)` + (the token belongs to the peer that presents it), and the expiration + check against the current time. +3. **The node's own identity facts**, taken from its own credential, as + `target_fact("group", "dev-nodes")` and similar. The caller's + `allowed_targets` are matched against these facts. The destination node + proves that it is an intended target; the origin node does not check its + own traffic. +4. **The node's local rules**, from the `attenuation` block of its + configuration file: extra facts, extra checks, and `allow` and `deny` + policies. +5. **The baseline policies**: `allow if service($t,$n), granted_service_exact($t,$n)` + and the equivalent policies for sets, prefixes, suffixes, per-type and + global wildcards, plus the target check + `allow_network_target(...) or target_unrestricted()`. +6. **The synced mesh policy rules** described above. + +Biscuit evaluates every `check` and requires all of them to pass. It then +walks the policies in order and applies the first `allow` or `deny` that +matches. As a result, a failing check denies the request regardless of any +policy. A local `deny` placed before the baseline `allow` overrides a grant. +A local `allow` can admit a caller that the policy did not grant, but it can +never admit a caller that fails a check. + +Before any of this, the connection itself is gated. A peer on the ban list is +dropped at the transport layer, and a peer whose credential does not verify +under a trusted signing key cannot name a service at all. + +## Local rules + +The `attenuation` block in `sam-node.yaml` gives the hosting node the final +say. Use it for constraints that the operator of that node wants regardless +of what the mesh policy grants: + +```yaml +attenuation: + rules: + - 'maintenance() <- time($t), $t > 2026-12-31T00:00:00Z;' + checks: + - 'check if label("jurisdiction", "eu");' # every caller must carry this label + policies: + - 'deny if service("mcp", "db-writer"), group("contractors");' + - 'deny if maintenance();' +``` + +`rules` derive new facts, `checks` must all hold, and `policies` are +evaluated before the baseline policies. A syntax error in any of them stops +the node at start, so a broken rule cannot weaken the node without notice. +The mobile app has the same block, with the same syntax, in its settings. + +## Labels + +Labels are `key=value` pairs. A node declares them in its configuration, and +the control plane writes them into the node's credential as `label(k, v)` +facts, one per label, if a role the node holds allows them. Labels let +policy describe where a node is or what it is for. SAM does not define a +fixed set of keys: `region`, `jurisdiction`, `team`, `compliance`, or +whatever the operator needs. + +Labels are used in three places: + +- **A provider restricting callers** adds `check if label("region", "eu")` + to its `attenuation.checks`. Every caller's credential must then carry + that label. +- **A caller choosing providers** sends `X-Sam-Required-Labels: region=eu` + on the node's `/v1` inference endpoints or on a proxied A2A request, or + passes `required_labels` to `call_remote_tool`. Before it sends any request + data, the calling node fetches the provider's credential through the + mutual handshake, verifies it, and confirms that the label facts are + present. A provider that cannot show them is skipped. +- **An operator drawing a boundary** sets `egress.require_labels` in the + node configuration. Every provider this node talks to must attest all of + those labels, whether or not the caller asked for any. The caller can add + further requirements but cannot remove the operator's. + +The header and the operator floor have different matching rules, and the +difference follows from their purpose. The header is any-of: the caller is +choosing among acceptable providers. The floor is all-of: the operator is +drawing a line. Labels seen in discovery results are only used to rank +candidates. The only labels that authorize anything are the signed ones in a +credential. + +## Agents acting through a node + +A node may forward requests on behalf of a sandboxed agent and name that +agent to the destination. The name travels next to the token, not inside it. +The destination accepts the name only if the calling node's credential +grants that agent namespace through `allowed_agents`. A node with no such +grant cannot name any agent. This is attribution, not proof, and that is why +the namespace belongs to the node's role and not to the agent. The +[sandboxed agents preview](../../preview/sandboxed-agents/) has the details. + +## See also + +- [Policy reference](../../reference/policy/): every field, pattern and fact + name. +- [Node configuration reference](../../reference/node-config/): the + `attenuation`, `labels` and `egress` blocks. diff --git a/site/content/docs/concepts/boundaries.md b/site/content/docs/concepts/boundaries.md new file mode 100644 index 00000000..75a66cf3 --- /dev/null +++ b/site/content/docs/concepts/boundaries.md @@ -0,0 +1,111 @@ +--- +title: "Control and data boundaries" +linkTitle: "Boundaries" +weight: 5 +aliases: + - /docs/sovereignty/ +--- + +When you run your own control plane, you decide everything about the mesh. +On the public testnets, someone else decides who joins and what the policy +says. This page lists the decisions that become yours in a dedicated +deployment and the mechanism that enforces each of them. + +## What you control in a dedicated deployment + +- **Membership.** Enrollment goes through your identity provider or through + bootstrap tokens that you mint. The bindings in the control plane decide + which identities receive which roles. An identity without a binding cannot + enroll a node. +- **The signing key.** The control plane generates its Ed25519 signing keys + and stores them in its database. Every credential in the mesh is signed by + one of these keys, and nobody outside the deployment can mint or extend a + credential. The keys rotate on the schedule you set. +- **The policy.** What each role may call, on which nodes and with which + labels, is stored in your database. It can only be changed with your admin + token or through your console. +- **Revocation.** A ban takes effect on the next credential refresh (within + the credential TTL, 24 hours by default) and, through the mesh event + channel, immediately on every connected node. +- **Where the software runs.** SAM is Apache-2.0, sends no telemetry, and + does not depend on any hosted service. The control plane, routers and + nodes run wherever you put them: a laptop, a private cluster, an + air-gapped network. + +The public testnets give you none of this. They are useful for trying the +software. Do not put anything there that you would not publish. + +## Where data goes + +The control plane is not on the data path. A request from an agent goes from +its node to the provider's node over an encrypted libp2p stream. The stream +is relayed through a router only when the two nodes cannot connect directly, +and the relay sees only ciphertext. No part of a request, its arguments or +its result is recorded centrally. + +Routers are on the path, so place them with care. If the nodes in a region +use a router in that region, relayed traffic between them stays in the +region. + +## Keeping data inside a boundary + +Labels are how a deployment expresses facts such as "this node is in the EU" +or "this node handles HIPAA data". A node declares its labels, the control +plane attests only the labels that the node's role permits, and the labels +become signed facts in the node's credential. Three mechanisms can then hold +a boundary: + +- **A provider refuses callers from outside the boundary.** + `check if label("jurisdiction", "eu")` in the provider node's + `attenuation.checks` rejects any caller whose credential lacks that fact, + regardless of what the mesh policy granted. +- **A caller refuses providers outside the boundary.** With + `X-Sam-Required-Labels` on a request, the calling node verifies the + provider's credential before it sends anything. A provider that cannot + show the label is skipped. +- **An operator sets a floor for a whole node.** `egress.require_labels` in + the node configuration applies to every outbound request from that node. + Callers cannot lower it. + +All three mechanisms act on signed facts, not on discovery data, and all +three fail closed. A provider whose credential cannot be fetched or verified +is treated as if it did not have the label. + +## The host has the final say + +The node that publishes a service decides who calls it. The mesh policy +grants access. The node's `attenuation` block can add checks that the caller +must also satisfy, and `deny` rules that override a grant. This is +intentional. The central policy states what the operator of the mesh +intends. The operator of the machine that holds the data can always refuse. + +## Evidence + +Two endpoints on the node's local API expose what the node knows, for audit +or automation. They are reachable only over the node's Unix socket or over +mTLS. + +- `GET /sam/identity` returns the node's own credential, the control plane + public key it was verified against, and its roles, labels and expiry. +- `GET /sam/peer/{peer-id}/evidence` returns the same information for a peer + that the node has authenticated. + +Both results can be verified offline with the control plane's public key. + +## Why credentials expire + +A credential lasts 24 hours by default and is renewed in the background. +Short lifetimes make revocation work without a central check on every +request. A stolen credential is useful for at most its remaining lifetime. A +banned node cannot renew. A node that has been offline for longer than the +signing key's grace period has to come back through enrollment instead of +reappearing without notice. The lifetime, the session length, the rotation +interval and the grace period are all control plane flags that the operator +sets. + +## See also + +- [Identity](../identity/) for enrollment, refresh, rotation and revocation. +- [Authorization](../authorization/) for labels and local rules in detail. +- [Kubernetes](../../guides/kubernetes/) and [your own mesh](../../getting-started/your-own-mesh/) + for deploying a control plane. diff --git a/site/content/docs/concepts/identity.md b/site/content/docs/concepts/identity.md new file mode 100644 index 00000000..1b9a6c6d --- /dev/null +++ b/site/content/docs/concepts/identity.md @@ -0,0 +1,185 @@ +--- +title: "Identity and enrollment" +linkTitle: "Identity" +weight: 2 +--- + +Every participant in a mesh, node or router, has a key that it generated +itself and a credential that the control plane issued for that key. This page +follows the credential from enrollment to expiry: how a node gets it, what it +contains, how it is renewed, and how it is revoked. + +## Keys and peer IDs + +The first thing a node does is generate an Ed25519 key pair and store it in +its data directory (`~/.config/sam-mesh/agent.db` by default). Its **peer +ID**, the `12D3KooW...` string that appears in logs, discovery results and +proxy URLs, is derived from the public key. The key never leaves the machine. +The credential is bound to the key, so a copied credential is useless without +it, and every peer-to-peer connection proves possession of the key as part +of the libp2p handshake. + +`sam-node reset --all` deletes the key and gives the node a new peer ID. +`sam-node reset` without `--all` keeps the key and deletes only the +credential. + +## Enrollment + +Enrollment is a request to the control plane: "here is my public key, here is +who I am, please issue me a credential for role *X* with labels *Y*". There +are three ways to say who you are. + +**Interactive OIDC login.** `sam-node join ` fetches the +control plane's `/info`, learns which identity provider it trusts, and runs an +OpenID Connect login. If a browser is available, it uses the loopback flow. +On a headless machine it uses the device flow (a URL and a code that you enter +on another device), or falls back to pasting a code. `--auth-mode` selects +one of these explicitly. The ID token from the login is sent to +`POST /register` together with the public key and a signature over a fresh +challenge. This is how a person enrolls a laptop. + +**Non-interactive OIDC.** A workload that already has an OIDC token does not +need a login. `sam-node run --jwt-path ` enrolls with the token in that +file. On Kubernetes this is a projected service account token with the +audience the control plane expects. `--client-id` and `--client-secret-path` +do the same with an OAuth client-credentials grant. Routers enroll in the +same way with `sam-router --jwt-path`. + +**Bootstrap token.** An operator mints a token with the admin API, the +console, or `sam-one token create`, and copies it to the machine. The node +sends it to `POST /enroll`. Unless the control plane runs with +`--auto-approve-enrollment`, the request waits in a queue until an +administrator approves it, and the node polls `GET /enroll/status` while it +waits. A token has a role, an expiry and a usage count. A single-use token is +spent as soon as one node is approved with it. This is how machines without +an identity of their own enroll, and how `sam-one` enrolls devices by QR +code. + +For all three paths, the control plane checks the same things before it +mints a credential: + +1. The peer ID matches the submitted public key, and the challenge signature + verifies under that key. This proves that the requester holds the key it + is registering. +2. Neither the peer ID nor the identity behind it is banned. +3. The identity resolves, through the bindings in the mesh policy, to the + role being requested. `sam-node` requests `sam:role:node`, `sam-router` + requests `sam:role:router`, and `sam-box` requests `sam:role:sambox`. If + the policy binds nobody to `sam:role:node`, no node can enroll. +4. Every label the node declared is permitted by the `allowed_labels` of a + role it holds. A role without `allowed_labels` permits no labels. + +The node stores the credential, the control plane's public key, the control +plane URL and the router addresses it received. From then on it is a member +of that mesh only. If you point it at a different control plane later, it +refuses until you reset it, because a credential is only valid for the mesh +that issued it. + +## The credential + +The credential is a [Biscuit](https://www.biscuitsec.org/), a signed +authorization token. Its authority block holds facts written in Datalog, a +small logic language in which a fact looks like `role("sam:role:node")`. The +block is signed by the control plane's Ed25519 key. Any node with the public +key can verify it without contacting anyone. The block contains: + +| Fact | Meaning | +|---|---| +| `node("12D3KooW...")`, `client_peer_id("12D3KooW...")` | The peer ID the token belongs to. Every verifier checks that the connection it arrived on was authenticated as this peer. | +| `expiration(