diff --git a/.gitignore b/.gitignore index 56ed5cd4..09fd213c 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ mobile/logcat.txt key.properties google-services.json + +# Recordings of development/examples/sandboxed-agent/record.sh +development/examples/sandboxed-agent/out/ diff --git a/development/examples/sandboxed-agent/Makefile b/development/examples/sandboxed-agent/Makefile new file mode 100644 index 00000000..125adc98 --- /dev/null +++ b/development/examples/sandboxed-agent/Makefile @@ -0,0 +1,91 @@ +# The admin's side of the demo: a mesh on a public URL, one node in the +# office fronting a model, an MCP server and api.github.com, and the two +# tokens that admit the node and the agent. Each server target runs in the +# foreground; give it its own terminal. +# +# make ollama the office model, in Docker +# make mcp the MCP reference server on :3001 +# make mesh sam-one on a public https URL (banner prints it) +# make tokens URL=https://... one token for the node, one for the agent +# make pep URL=https://... the node, with the GitHub credential on its host +# make revoke URL=https://... take api.github.com off the mesh +# make ban URL=... PEER=12D3... cut one member off +# make clean forget everything + +DEMO_DIR ?= $(HOME)/sam-demo +REPO_BIN := $(abspath ../../../bin) +SAM_ONE ?= $(if $(wildcard $(REPO_BIN)/sam-one),$(REPO_BIN)/sam-one,sam-one) +SAM_NODE ?= $(if $(wildcard $(REPO_BIN)/sam-node),$(REPO_BIN)/sam-node,sam-node) +# The read-only GitHub token the node presents. Never a flag value: a file. +GITHUB_TOKEN_FILE ?= $(HOME)/.config/sam-demo/github-ro +MODEL ?= gemma3:1b +PORT ?= 8090 + +.PHONY: ollama mcp mesh tokens secrets pep policy revoke ban clean + +ollama: + docker start sam-demo-ollama 2>/dev/null || \ + docker run -d --name sam-demo-ollama -p 127.0.0.1:11434:11434 -v sam-demo-ollama:/root/.ollama ollama/ollama >/dev/null + @for _ in $$(seq 1 30); do curl -sf http://127.0.0.1:11434/ >/dev/null && break; sleep 1; done + docker exec sam-demo-ollama ollama pull $(MODEL) + curl -s http://127.0.0.1:11434/v1/models + +mcp: + npx -y @modelcontextprotocol/server-everything streamableHttp + +mesh: + $(SAM_ONE) --data-dir "$(DEMO_DIR)/one" --port $(PORT) \ + --tunnel cloudflare --tunnel-install \ + --policy-file policy.json --no-join-token --enroll-qr=false --log-level warn + +tokens: + @test -n "$(URL)" || { echo "make tokens URL=https://... (the API URL from the sam-one banner)"; exit 1; } + @mkdir -p "$(DEMO_DIR)" + @$(SAM_ONE) token create --server "$(URL)" --data-dir "$(DEMO_DIR)/one" \ + --max-usages 1 --description "office node" 2>/dev/null \ + | awk '/^Token:/ {print $$2}' > "$(DEMO_DIR)/pep-token" + @$(SAM_ONE) token create --server "$(URL)" --data-dir "$(DEMO_DIR)/one" \ + --role agent --max-usages 1 --description "agent in the dev sandbox" 2>/dev/null \ + | awk '/^Token:/ {print $$2}' > "$(DEMO_DIR)/agent-token" + @test -s "$(DEMO_DIR)/pep-token" -a -s "$(DEMO_DIR)/agent-token" || { echo "token create failed"; exit 1; } + @echo "tokens in $(DEMO_DIR)/pep-token (role sam:role:node) and $(DEMO_DIR)/agent-token (role agent)" + +secrets: + @test -s "$(GITHUB_TOKEN_FILE)" || { echo "GITHUB_TOKEN_FILE=$(GITHUB_TOKEN_FILE) is missing: a read-only fine-grained token for google/sam"; exit 1; } + @mkdir -p "$(DEMO_DIR)/secrets" + @install -m 600 "$(GITHUB_TOKEN_FILE)" "$(DEMO_DIR)/secrets/github-ro" + @echo "credential github-ro in $(DEMO_DIR)/secrets" + +pep: secrets + @test -n "$(URL)" || { echo "make pep URL=https://..."; exit 1; } + $(SAM_NODE) run --control-plane "$(URL)" \ + --bootstrap-token-path "$(DEMO_DIR)/pep-token" \ + --config pep.yaml --secrets-dir "$(DEMO_DIR)/secrets" \ + --data-dir "$(DEMO_DIR)/pep" --bind-addr= \ + --control-plane-sync-interval 30s + +# The admin token: the one sam-one generated into the data dir, unless the +# environment supplies it (then the banner does not show it either). +ADMIN_TOKEN = $${SAM_ADMIN_TOKEN:-$$(cat "$(DEMO_DIR)/one/admin-token")} + +policy: + @test -n "$(URL)" || { echo "make policy URL=https://..."; exit 1; } + @echo "POST policy.json to $(URL)/policies" + @curl -sS -X POST "$(URL)/policies" \ + -H "Authorization: Bearer $(ADMIN_TOKEN)" \ + -H 'Content-Type: application/json' --data @policy.json; echo + +revoke: + @test -n "$(URL)" || { echo "make revoke URL=https://..."; exit 1; } + @echo "POST policy.json without its egress section to $(URL)/policies" + @jq 'del(.egress)' policy.json | curl -sS -X POST "$(URL)/policies" \ + -H "Authorization: Bearer $(ADMIN_TOKEN)" \ + -H 'Content-Type: application/json' --data @-; echo + +ban: + @test -n "$(URL)" -a -n "$(PEER)" || { echo "make ban URL=https://... PEER=12D3KooW..."; exit 1; } + @$(SAM_ONE) admin ban "$(PEER)" --server "$(URL)" --data-dir "$(DEMO_DIR)/one" + +clean: + rm -rf "$(DEMO_DIR)" + docker rm -f sam-demo-ollama >/dev/null 2>&1 || true diff --git a/development/examples/sandboxed-agent/SCRIPT.md b/development/examples/sandboxed-agent/SCRIPT.md new file mode 100644 index 00000000..fafac4a9 --- /dev/null +++ b/development/examples/sandboxed-agent/SCRIPT.md @@ -0,0 +1,185 @@ +# An agent in a sandbox, a network the admin controls + +A three to four minute recording. Two machines: the admin's workstation +("the office", left) and a developer sandbox with no route into the office +(a GitHub codespace, right). A caption line at the bottom carries the +narration below, so the recording explains itself and every command is on +screen. `record.sh` produces it; the sections below are what it types and +what it says. + +## Before recording + +On the workstation: + +```bash +make build # ./bin/sam-one and ./bin/sam-node +cd development/examples/sandboxed-agent +make ollama # the office model, in Docker +make mcp & # the MCP reference server on :3001 +export GITHUB_TOKEN_FILE=~/.config/sam-demo/github-ro # read-only, google/sam pull requests +``` + +The sandbox: a codespace on `google/sam` (any configuration with Python), +with the SDK installed and `agent.py` in `~/sandbox`: + +```bash +python3 -m venv ~/venv && ~/venv/bin/pip install ./sdk/python +mkdir ~/sandbox && cp development/examples/sandboxed-agent/agent.py ~/sandbox/ +``` + +The developer receives the single-use token the admin mints in scene 2 out +of band, as `~/sandbox/agent-token`. The recording does not show a token. + +## Scenes + +Narration is what the caption pane shows while the commands run. + +### 0. The problem + +> An agent decides at run time which API it calls and with what. You cannot +> review that in a pull request. Security wants it sandboxed. The developer +> wants the model, the tools and the internal API it needs. A VPN into the +> office is the usual bridge: slow to develop against, and it turns the +> sandbox into a door with credentials inside. + +### 1. Two machines + +Left, the office. Right, a sandbox somewhere else: a codespace on GitHub. + +```bash +# sandbox +curl -m 3 http://office-llm.corp.internal:11434/v1/models +``` + +> The sandbox has no route into the office. It has a route to one URL, and +> the mesh decides what is behind it. + +### 2. The admin starts a mesh and writes one policy + +```bash +# office +make mesh +``` + +> One command: a control plane, a router and a console, on a public https +> URL, with the policy in `policy.json`. No standing join token: every +> member gets a token minted for its role. + +```bash +# office +export URL=https:// +make tokens URL=$URL +make pep URL=$URL 2>&1 | python3 audit.py +``` + +> The office node fronts three things: a model on loopback, an MCP server +> on loopback, and `api.github.com` with a read-only token in a file on +> this machine. The policy assigns the destination to the node by its +> label, `site=office`. The role `agent` may call the three by name, and +> `api.github.com` only with `GET` under `/repos/google/sam/`. + +`policy.json` is on screen here, shortened to the `agent` role and the +`egress` entry. + +### 3. The developer's program joins + +```bash +# sandbox +export SAM_CONTROL_PLANE_URL=$URL SAM_BOOTSTRAP_TOKEN_PATH=~/sandbox/agent-token +python agent.py models +``` + +> The agent is an ordinary Python program with the SDK. It enrolls once, +> with a single-use token for the role `agent`, and gets an identity. +> Nothing else runs in the sandbox: no sidecar, no proxy variables, no VPN. +> The model in the office answers by name. + +### 4. A model, a tool + +```bash +# sandbox +python agent.py ask +python agent.py tool +``` + +> A chat completion runs on the office workstation. A tool call reaches +> the MCP server there. Every decision is one line in the node's log. + +### 5. An external API, with a credential the sandbox never held + +```bash +# sandbox +python agent.py github GET '/repos/google/sam/pulls?state=open&per_page=1' +``` + +> GitHub answers 200. The node presented the office's token; the request +> the sandbox sent had none. + +```bash +# sandbox +python agent.py github POST /repos/google/sam/pulls +python agent.py github GET /user +``` + +> The agent may try anything. `POST` is outside the grant. `/user` is +> outside the grant. The network answers 403 before GitHub hears of it. + +### 6. The admin takes the destination off the mesh + +```bash +# office +make revoke URL=$URL +# sandbox +python agent.py github GET '/repos/google/sam/pulls?state=open&per_page=1' +``` + +> One policy change. The node withdraws `api.github.com` within seconds, +> and the same request finds no service. Nothing to revoke in the sandbox: +> it never had anything. + +### 7. The admin cuts the agent off + +```bash +# office +make ban URL=$URL PEER= +# sandbox +python agent.py github GET /user +``` + +> The identity is banned. No router admits it again. + +### 8. Close + +> The developer used their own sandbox and wrote a plain program. The +> admin wrote one policy document and read one log. The credential never +> left the office. + +## Notes for the narrator + +- Grants live in the member's credential for its lifetime (24 hours by + default, `--control-plane-biscuit-ttl`). Removing a service from a role + takes effect at the next refresh; removing a destination or banning a + member takes effect within the node's sync jitter, which is why scenes 6 + and 7 use those two. +- The node polls the control plane every 30 seconds in this demo + (`--control-plane-sync-interval`) and reacts to policy events within a + tenth of that. The default is 15 minutes. +- The model's answer is different every take. That is the point of scene 0. + +## Recording + +```bash +CODESPACE= GITHUB_TOKEN_FILE=~/github-ro ./record.sh # tmux, asciinema, then agg and ffmpeg +``` + +The captions are typed into their pane at reading speed (`CAPTION_CPS`, +24 characters per second), and the driver moves on when a caption is fully +shown, so the narration paces the take. Afterwards every silence in the +cast longer than `IDLE_MAX` (1.5 s) is cut to that length: a join, a +tunnel coming up, a timeout. Typing is untouched. The video is rendered at +`SPEED` (1.25) and the subtitles are remapped through both, so they stay +in sync. + +Outputs `demo.cast`, `demo.gif`, `demo.mp4` and `demo.srt` (the narration +with timestamps, for a voice-over or subtitles) in `out/`, which git +ignores. The published copy is `site/static/demo-sandboxed-agent.mp4`. diff --git a/development/examples/sandboxed-agent/agent.py b/development/examples/sandboxed-agent/agent.py new file mode 100644 index 00000000..f2f354a9 --- /dev/null +++ b/development/examples/sandboxed-agent/agent.py @@ -0,0 +1,163 @@ +"""An agent in a sandbox, on the mesh. + +The sandbox has no route into the office. This program has one identity on +the mesh and reaches, by name, what the mesh policy grants that identity: a +model, an MCP server and an external API whose credential it never holds. + + python agent.py every step below, in order + python agent.py models list the office model + python agent.py ask "..." one chat completion + python agent.py tool call an MCP tool + python agent.py github GET /repos/google/sam/pulls?state=open&per_page=1 + python agent.py github POST /repos/google/sam/pulls + python agent.py github GET /user + +SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the token in +SAM_BOOTSTRAP_TOKEN_PATH and keeps identity and credential in SAM_STATE_DIR. +""" + +import json +import logging +import os +import sys + +import trio +from agent_mesh import AgentMesh + +LLM = "inference://office-llm" +TOOLS = "mcp://tools" +GITHUB = "egress://api.github.com" + +# py-libp2p narrates every failed dial over several lines; the verdicts this +# program prints are the story. SAM_DEBUG=1 brings the narration back. +if not os.environ.get("SAM_DEBUG"): + logging.getLogger("libp2p").setLevel(logging.CRITICAL) + +mesh = AgentMesh.enroll( + os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"), + bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"), + # The role the token was minted for; the policy's grants hang off it. + role=os.environ.get("SAM_ROLE", "agent"), + state_dir=os.environ.get("SAM_STATE_DIR", "~/.config/sam-mesh/sandboxed-agent"), + allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true", +) + + +def say(line: str) -> None: + print(line, flush=True) + + +_providers = {} + + +async def reach(session, service): + """The first provider of service that answers, remembered for this run.""" + if service in _providers: + return _providers[service] + providers = await session.discover(service) + if not providers: + raise SystemExit(f"nobody on the mesh serves {service}") + for provider in providers: + try: + await session.connect(provider) + _providers[service] = provider + return provider + except (ConnectionError, PermissionError) as err: + # A provider record can outlive its member; say so in one line. + print(f"{provider.peer_id[:16]}… not reachable: {str(err).splitlines()[0][:80]}", file=sys.stderr) + raise SystemExit(f"no provider of {service} is reachable") + + +async def models(session): + pep = await reach(session, LLM) + say(f"→ GET {LLM} /v1/models") + resp = await session.request(pep, LLM, "/v1/models") + ids = [m["id"] for m in resp.json().get("data", [])] + say(f"← {resp.status} {', '.join(ids)} (served by {pep.peer_id[:16]}…, in the office)") + return ids + + +async def ask(session, question): + pep = await reach(session, LLM) + ids = (await session.request(pep, LLM, "/v1/models")).json().get("data", []) + model = os.environ.get("MODEL") or (ids[0]["id"] if ids else "gemma3:1b") + say(f"→ POST {LLM} /v1/chat/completions model={model}") + say(f" {question}") + resp = await session.request( + pep, LLM, "/v1/chat/completions", + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps({"model": model, "messages": [{"role": "user", "content": question}]}), + ) + if resp.status != 200: + say(f"← {resp.status} {resp.text[:200]}") + return + say(f"← {resp.status} {resp.json()['choices'][0]['message']['content'].strip()}") + + +async def tool(session): + pep = await reach(session, TOOLS) + tools = await session.list_tools(pep, TOOLS) + say(f"→ {TOOLS} {len(tools)} tools: {', '.join(t.name for t in tools[:4])}, …") + say("→ call get-sum(a=2, b=3)") + result = await session.call_tool(pep, TOOLS, "get-sum", {"a": 2, "b": 3}) + say(f"← {' '.join(result.text)}") + + +async def github(session, method, path): + pep = await reach(session, GITHUB) + say(f"→ {method} {GITHUB} {path}") + resp = await session.request( + pep, GITHUB, path, + method=method, + # GitHub refuses requests without a User-Agent; the node forwards it. + headers={"Accept": "application/vnd.github+json", "User-Agent": "sam-sandboxed-agent"}, + body=b"{}" if method in ("POST", "PUT", "PATCH") else None, + ) + if resp.status == 200: + data = resp.json() + if isinstance(data, list) and data and "number" in data[0]: + say(f"← {resp.status} #{data[0]['number']} {data[0]['title']}") + else: + say(f"← {resp.status} {resp.text[:120]}") + return + verdict = resp.headers.get("proxy-status") or resp.headers.get("Proxy-Status") or "" + say(f"← {resp.status} {verdict or resp.text[:120]}") + + +async def run(session, argv): + say(f"on the mesh as {session.peer_id}") + step = argv[0] if argv else "all" + if step == "models": + await models(session) + elif step == "ask": + await ask(session, " ".join(argv[1:]) or "In one sentence, in English: why should an agent never hold API credentials?") + elif step == "tool": + await tool(session) + elif step == "github": + await github(session, argv[1].upper(), argv[2]) + elif step == "all": + await models(session) + await ask(session, "In one sentence, in English: why should an agent never hold API credentials?") + await tool(session) + await github(session, "GET", "/repos/google/sam/pulls?state=open&per_page=1") + await github(session, "POST", "/repos/google/sam/pulls") + await github(session, "GET", "/user") + else: + raise SystemExit(__doc__) + + +async def main(argv): + joined = False + try: + async with mesh.join() as session: + joined = True + await run(session, argv) + except RuntimeError as err: + if joined: + raise + # Every router refused this identity: the admin cut it off. + raise SystemExit(f"cut off from the mesh: {str(err).splitlines()[0].rstrip(':')}") + + +trio.run(main, sys.argv[1:]) diff --git a/development/examples/sandboxed-agent/audit.py b/development/examples/sandboxed-agent/audit.py new file mode 100644 index 00000000..478adb1d --- /dev/null +++ b/development/examples/sandboxed-agent/audit.py @@ -0,0 +1,45 @@ +"""Reads a sam-node log on stdin and prints the lines an admin watches: what +the node serves, and one line per authorization decision. + + make pep URL=https://... 2>&1 | python3 audit.py +""" + +import json +import os +import re +import sys +from contextlib import nullcontext + +AUDIT = re.compile(r"Audit Traceability\s+(\{.*\})\s*$") +KEEP = re.compile(r"\[Egress\] (Serving|Withdrawn|Assignments)|peer banned|SAM Node Online|PeerID:") + + +def verdict(line): + m = AUDIT.search(line) + if m: + try: + d = json.loads(m.group(1)) + except json.JSONDecodeError: + return None + who = f"{d.get('role') or '-'} {d.get('peer_id', '')[:12]}…" + what = " ".join(x for x in (d.get("method"), d.get("path")) if x) or d.get("protocol", "") + return f"{d.get('decision', '?').upper():<5} {who:<22} {what:<40} {d.get('target', '')}" + if "peer banned" in line: + peer = re.search(r'"peer":\s*"([^"]+)"', line) + return f"BANNED {peer.group(1) if peer else ''}: the control plane cut this member off" + if KEEP.search(line): + # Drop the timestamp and logger columns; keep the message. + return (line.split("\t")[-1] if "\t" in line else line).strip() + return None + + +# AUDIT_RAW= keeps the unfiltered log next to the filtered view. +raw_path = os.environ.get("AUDIT_RAW") +with (open(raw_path, "a") if raw_path else nullcontext()) as raw: + for raw_line in sys.stdin: + if raw: + raw.write(raw_line) + raw.flush() + out = verdict(raw_line.rstrip("\n")) + if out: + print(out, flush=True) diff --git a/development/examples/sandboxed-agent/pep.yaml b/development/examples/sandboxed-agent/pep.yaml new file mode 100644 index 00000000..c9dd6e78 --- /dev/null +++ b/development/examples/sandboxed-agent/pep.yaml @@ -0,0 +1,15 @@ +# The node in the office. It fronts two things that listen on loopback and +# are reachable from nowhere else; api.github.com is assigned to it by the +# mesh policy (egress.served_by selects the label below), not declared here. +version: "v1alpha1" +labels: + site: office +services: + - type: inference + name: office-llm + description: "Ollama on the office workstation" + target_url: "http://127.0.0.1:11434" + - type: mcp + name: tools + description: "MCP reference server" + target_url: "http://127.0.0.1:3001/mcp" diff --git a/development/examples/sandboxed-agent/policy.json b/development/examples/sandboxed-agent/policy.json new file mode 100644 index 00000000..ab213ed6 --- /dev/null +++ b/development/examples/sandboxed-agent/policy.json @@ -0,0 +1,21 @@ +{ + "roles": [ + { + "name": "sam:role:node", + "allowed_services": ["system://sam.catalog"], + "allowed_targets": ["*"], + "allowed_labels": ["site=office"] + }, + { + "name": "agent", + "allowed_services": ["inference://office-llm", "mcp://tools", "egress://api.github.com"], + "allowed_targets": ["*"], + "http": [ + { "service": "egress://api.github.com", "methods": ["GET"], "paths": ["/repos/google/sam/*"] } + ] + } + ], + "egress": [ + { "name": "api.github.com", "credential": "github-ro", "served_by": ["site=office"] } + ] +} diff --git a/development/examples/sandboxed-agent/record.sh b/development/examples/sandboxed-agent/record.sh new file mode 100755 index 00000000..749f4db1 --- /dev/null +++ b/development/examples/sandboxed-agent/record.sh @@ -0,0 +1,350 @@ +#!/bin/bash +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Records SCRIPT.md. A tmux window holds the office (left: mesh, admin, +# node), the sandbox (right: a shell in a codespace) and a caption line +# (bottom). The scenes are typed into the panes at a human pace and paced +# on what appears in them, so every take is the same. asciinema captures +# the window; agg and ffmpeg render it. +# +# CODESPACE= ./record.sh +# +# Needs: make ollama and make mcp already running, ./bin built, the +# GitHub token at $GITHUB_TOKEN_FILE, and the codespace prepared as +# SCRIPT.md describes (SDK in ~/venv, agent.py and agent-token in ~/sandbox). + +set -o errexit +set -o nounset +set -o pipefail + +CODESPACE=${CODESPACE:?set CODESPACE to the codespace name (gh codespace list)} +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +OUT=$HERE/out +SESSION=samdemo +COLS=${COLS:-190} +ROWS=${ROWS:-54} +# Playback speed of the rendered video; the subtitles are scaled to match. +SPEED=${SPEED:-1.25} +# Longest silence kept, in seconds of the take: a join or a tunnel coming up +# is cut to this, typing and typed captions are untouched. +IDLE_MAX=${IDLE_MAX:-1.5} +# Caption typing rate, characters per second; sets the reading time. +CAPTION_CPS=${CAPTION_CPS:-24} +CAPTION_FILE=/tmp/samdemo-caption.txt +CAPTIONS=$OUT/captions.tsv +SRT=$OUT/demo.srt +GITHUB_TOKEN_FILE=${GITHUB_TOKEN_FILE:-$HOME/.config/sam-demo/github-ro} +DEMO_DIR=${DEMO_DIR:-$HOME/sam-demo} + +mkdir -p "$OUT" +rm -f "$SRT" "$CAPTIONS" +: > "$CAPTION_FILE" + +# --- helpers --------------------------------------------------------------- + +# type_in : types text like a person and presses Enter. +type_in() { + local pane=$1 text=$2 i c + for ((i = 0; i < ${#text}; i++)); do + c=${text:i:1} + tmux send-keys -t "$pane" -l -- "$c" + sleep "0.0$((RANDOM % 5 + 2))" + done + sleep 0.3 + tmux send-keys -t "$pane" Enter + # Let the shell echo the newline before the next keystroke arrives. + sleep 0.8 +} + +# wait_for [seconds]: waits until the pane shows regex. +wait_for() { + local pane=$1 re=$2 timeout=${3:-60} i + for ((i = 0; i < timeout * 2; i++)); do + if tmux capture-pane -p -t "$pane" -S -300 | grep -qE -- "$re"; then + return 0 + fi + sleep 0.5 + done + echo "timed out waiting for /$re/ in $pane" >&2 + tmux capture-pane -p -t "$pane" -S -40 >&2 + return 1 +} + +# grab : the first match of regex in the pane. +grab() { + tmux capture-pane -p -t "$1" -S -300 | grep -oE -- "$2" | head -1 +} + +# count : how many lines of the pane match regex. +count() { + tmux capture-pane -p -t "$1" -S -300 | grep -cE -- "$2" || true +} + +# wait_prompt [seconds]: waits until the pane's last line is a prompt. +wait_prompt() { + local pane=$1 timeout=${2:-30} i + for ((i = 0; i < timeout * 4; i++)); do + if tmux capture-pane -p -t "$pane" | sed -e :a -e '/^\s*$/{$d;N;ba' -e '}' | tail -1 | grep -qE '\$ ?$'; then + return 0 + fi + sleep 0.25 + done + return 0 +} + +# expect [seconds]: types command, then waits for a +# line matching regex that was not on screen before, and for the prompt. +expect() { + local pane=$1 cmd=$2 re=$3 timeout=${4:-60} before i + before=$(count "$pane" "$re") + type_in "$pane" "$cmd" + for ((i = 0; i < timeout * 2; i++)); do + if (( $(count "$pane" "$re") > before )); then + wait_prompt "$pane" + return 0 + fi + sleep 0.5 + done + echo "timed out waiting for new /$re/ in $pane after: $cmd" >&2 + tmux capture-pane -p -t "$pane" -S -40 >&2 + return 1 +} + +# caption : types text into the caption pane, waits until it is fully +# shown, and records it for the subtitles. +caption() { + printf '%s\t%s\n' "$(date +%s.%N)" "$1" >> "$CAPTIONS" + printf '%s\n' "$1" > "$CAPTION_FILE" + sleep "$(python3 -c 'import sys; print(len(sys.argv[1]) / float(sys.argv[2]) + 0.6)' "$1" "$CAPTION_CPS")" +} + +pause() { sleep "${1:-2}"; } + +cleanup() { + tmux kill-session -t "$SESSION" 2>/dev/null || true + tmux kill-session -t "${SESSION}-rec" 2>/dev/null || true +} +trap cleanup EXIT + +# --- the window ------------------------------------------------------------- + +cleanup +tmux new-session -d -s "$SESSION" -x "$COLS" -y "$ROWS" -c "$HERE" +tmux set -t "$SESSION" -g pane-border-status top +tmux set -t "$SESSION" -g pane-border-format ' #{pane_title} ' +tmux set -t "$SESSION" -g status off +MESH=$(tmux display -p -t "$SESSION:0.0" '#{pane_id}') +CAPTION=$(tmux split-window -P -F '#{pane_id}' -v -l 4 -t "$MESH" -c "$HERE") +SANDBOX=$(tmux split-window -P -F '#{pane_id}' -h -l 50% -t "$MESH" -c "$HERE") +NODE=$(tmux split-window -P -F '#{pane_id}' -v -l 20 -t "$MESH" -c "$HERE") +ADMIN=$(tmux split-window -P -F '#{pane_id}' -v -l 12 -t "$MESH" -c "$HERE") +tmux select-pane -t "$MESH" -T 'office · mesh (sam-one)' +tmux select-pane -t "$ADMIN" -T 'office · admin' +tmux select-pane -t "$NODE" -T 'office · node (what it serves, every decision)' +tmux select-pane -t "$SANDBOX" -T 'developer sandbox · a codespace, no VPN' +tmux select-pane -t "$CAPTION" -T '' + +# The caption pane types its file out whenever it changes, at CAPTION_CPS. +cat > /tmp/samdemo-caption.py <<'EOF' +import sys, textwrap, time +path, cols, cps = sys.argv[1], int(sys.argv[2]), float(sys.argv[3]) +last = None +while True: + try: + text = open(path).read().strip() + except FileNotFoundError: + text = "" + if text != last: + last = text + sys.stdout.write("\033[2J\033[H") + for line in textwrap.wrap(text, cols - 4)[:3]: + sys.stdout.write(" \033[1m") + for ch in line: + sys.stdout.write(ch) + sys.stdout.flush() + time.sleep(1 / cps) + sys.stdout.write("\033[0m\n") + sys.stdout.flush() + time.sleep(0.1) +EOF +tmux send-keys -t "$CAPTION" "python3 /tmp/samdemo-caption.py $CAPTION_FILE $COLS $CAPTION_CPS" Enter + +# Quiet prompts. The admin token comes from the environment so the banner +# names its source instead of showing it. +ADMIN_TOKEN=$(openssl rand -hex 16) +for p in "$MESH" "$ADMIN" "$NODE"; do + tmux send-keys -t "$p" "export SAM_ADMIN_TOKEN=$ADMIN_TOKEN GITHUB_TOKEN_FILE=$GITHUB_TOKEN_FILE DEMO_DIR=$DEMO_DIR AUDIT_RAW=$OUT/node.log PS1='office\$ '; clear" Enter +done +rm -f "$OUT/node.log" +tmux send-keys -t "$SANDBOX" "gh codespace ssh -c $CODESPACE" Enter +wait_for "$SANDBOX" '\$\s*$' 120 +tmux send-keys -t "$SANDBOX" "PS1='sandbox\$ '; cd ~/sandbox && source ~/venv/bin/activate && rm -rf ~/.config/sam-mesh/sandboxed-agent ~/sandbox/state && clear" Enter +wait_for "$SANDBOX" 'sandbox\$' 30 +# A fresh mesh every take; the downloaded cloudflared is kept. +rm -rf "$DEMO_DIR/pep" +if [[ -d "$DEMO_DIR/one" ]]; then + find "$DEMO_DIR/one" -mindepth 1 -maxdepth 1 ! -name bin -exec rm -rf {} + +fi + +# --- record ----------------------------------------------------------------- + +# The recorder runs in its own tmux session so its pty has the window's size. +tmux new-session -d -s "${SESSION}-rec" -x "$COLS" -y "$ROWS" \ + "asciinema rec -q --overwrite --cols $COLS --rows $ROWS -c 'tmux attach -t $SESSION' '$OUT/demo.cast'" +sleep 2 + +caption "An agent decides at run time which API it calls, and with what. You cannot review that in a pull request. Security wants it sandboxed; the developer wants the model, the tools and the internal API it needs." +pause 1 +caption "The usual bridge is a VPN into the office: slow to develop against, and it turns the sandbox into a door with credentials inside." +pause 1 + +# 1. Two machines +caption "Left: the office. Right: a developer sandbox somewhere else, here a GitHub codespace. It has no route into the office." +expect "$SANDBOX" "curl -m 3 http://office-llm.corp.internal:11434/v1/models" 'Could not resolve host|Connection timed out' 10 +pause 2 + +# 2. The mesh and the policy +caption "The admin starts a mesh: a control plane, a router and a console, on a public https URL, with the policy in policy.json. No standing join token." +expect "$MESH" "make mesh" 'SAM standalone mesh is ready' 120 +URL=$(grab "$MESH" 'https://[a-z0-9-]+\.trycloudflare\.com') +# A quick tunnel is routed a few seconds after cloudflared prints it, and +# once in a while it dies at once; either way, do not type into a dead URL. +for i in $(seq 1 30); do + [[ $(curl -s -o /dev/null -w '%{http_code}' "$URL/healthz") == 200 ]] && break + sleep 2 + if (( i == 30 )); then echo "the tunnel at $URL never answered; abort this take" >&2; exit 1; fi +done +pause 2 + +caption "One token per member, minted for its role. The office node gets sam:role:node and the label site=office; the agent gets the role agent." +type_in "$ADMIN" "export URL=$URL" +expect "$ADMIN" "make tokens URL=\$URL" 'tokens in' 30 +# The developer receives the token out of band; here, over ssh. +gh codespace ssh -c "$CODESPACE" -- 'cat > ~/sandbox/agent-token; chmod 600 ~/sandbox/agent-token' < "$DEMO_DIR/agent-token" 2>/dev/null +pause 1 + +type_in "$ADMIN" "jq -c '.roles[1].allowed_services, .roles[1].http[0], .egress[0]' policy.json" +caption "The policy: the agent may call the model, the MCP server and api.github.com by name, and api.github.com only with GET under /repos/google/sam/. The destination is served by nodes labelled site=office." +pause 1 + +caption "The office node fronts a model and an MCP server on loopback. api.github.com is assigned to it by the policy; its read-only token is a file on this machine, read by the node, never by an agent." +type_in "$NODE" "export URL=$URL" +expect "$NODE" "make pep URL=\$URL 2>&1 | python3 audit.py" 'SAM Node Online' 60 +pause 2 + +# 3. The developer's program joins +caption "The agent is an ordinary Python program with the SDK. The admin handed the developer the single-use token out of band. It enrolls once and gets an identity: no sidecar, no proxy variables, no VPN." +type_in "$SANDBOX" "export SAM_CONTROL_PLANE_URL=$URL SAM_BOOTSTRAP_TOKEN_PATH=~/sandbox/agent-token" +expect "$SANDBOX" "python agent.py models" '← 200 gemma3' 90 +PEER=$(grab "$SANDBOX" 'on the mesh as 12D3KooW[A-Za-z0-9]+' | awk '{print $NF}') +pause 1 + +# 4. A model, a tool +expect "$SANDBOX" "python agent.py ask" '← 200 ' 90 +caption "A chat completion runs on the office workstation. The answer is different every take; that is the point." +expect "$SANDBOX" "python agent.py tool" 'sum of 2 and 3' 60 +caption "A tool call reaches the MCP server in the office. On the left, every decision is one line in the node's log." +pause 1 + +# 5. External API +expect "$SANDBOX" "python agent.py github GET '/repos/google/sam/pulls?state=open&per_page=1'" '← 200 #' 60 +caption "An external API. GitHub answers 200: the node presented the office's token. The request the sandbox sent had none." +expect "$SANDBOX" "python agent.py github POST /repos/google/sam/pulls" 'http_request_denied' 60 +expect "$SANDBOX" "python agent.py github GET /user" 'http_request_denied' 60 +wait_for "$NODE" 'DENY.*GET /user' 30 +caption "The agent may try anything. POST is outside the grant; /user is outside the grant. The network answers 403 before GitHub hears of it." +pause 1 + +# 6. Revoke +caption "The admin takes api.github.com off the mesh: one policy change. The node withdraws it within seconds." +expect "$ADMIN" "make revoke URL=\$URL" 'success' 30 +wait_for "$NODE" 'Withdrawn egress://api.github.com' 60 +expect "$SANDBOX" "python agent.py github GET '/repos/google/sam/pulls?state=open&per_page=1'" '← 404' 60 +caption "The same request finds no service. Nothing to revoke in the sandbox: it never had anything." +pause 1 + +# 7. Ban +caption "And when the admin decides this agent is done: the identity is banned, and no router admits it again." +expect "$ADMIN" "make ban URL=\$URL PEER=$PEER" 'banned' 30 +wait_for "$NODE" 'BANNED' 30 +expect "$SANDBOX" "python agent.py github GET /user" 'cut off from the mesh' 90 +pause 2 + +# 8. Close +caption "The developer used their own sandbox and wrote a plain program. The admin wrote one policy document and read one log. The credential never left the office." +pause 1 +caption "sam-mesh.dev" +pause 3 + +# Ends the recording: the attached client exits with the session. +END=$(date +%s.%N) +tmux kill-session -t "$SESSION" +for _ in $(seq 1 30); do tmux has-session -t "${SESSION}-rec" 2>/dev/null || break; sleep 1; done + +# --- render ------------------------------------------------------------------- + +# Cut the teardown, clamp every silence to IDLE_MAX, and write the subtitles +# on the clamped timeline at playback speed. +python3 - "$OUT/demo.cast" "$CAPTIONS" "$SRT" "$END" "$IDLE_MAX" "$SPEED" <<'EOF' +import bisect, json, sys +cast, captions, srt, end, idle_max, speed = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4]), float(sys.argv[5]), float(sys.argv[6]) +lines = open(cast).read().splitlines() +header = json.loads(lines[0]) +events = [json.loads(l) for l in lines[1:]] +caps = [(float(w), text) for w, text in (l.split("\t", 1) for l in open(captions).read().splitlines())] +# The cast clock starts a little after the header's whole-second timestamp. +# The first caption is typed right after the only long silence at the start +# (the attach redraw, then the driver's sleep), which anchors the two clocks. +first_typing = next((e[0] for prev, e in zip([[0.0]] + events, events) if e[0] - prev[0] > 1.0 and e[0] < 10), 1.5) +t0 = caps[0][0] - first_typing +events = [e for e in events if e[0] <= end - t0 - 0.5] + +knots_old, knots_new = [], [] +prev_old = prev_new = 0.0 +for e in events: + prev_new += min(e[0] - prev_old, idle_max) + prev_old = e[0] + knots_old.append(prev_old) + knots_new.append(prev_new) + e[0] = round(prev_new, 6) + +def remap(t): + i = bisect.bisect_right(knots_old, t) - 1 + if i < 0: + return 0.0 + return knots_new[i] + min(t - knots_old[i], idle_max) + +def stamp(t): + t = max(t, 0) / speed + h, m, s = int(t // 3600), int(t % 3600 // 60), t % 60 + return f"{h:02d}:{m:02d}:{int(s):02d},{int((s - int(s)) * 1000):03d}" + +caps = [(w - t0, text) for w, text in caps] +last = (events[-1][0] if events else 0.0) + idle_max +with open(srt, "w") as out: + for i, (t, text) in enumerate(caps): + stop = remap(caps[i + 1][0]) if i + 1 < len(caps) else last + out.write(f"{i + 1}\n{stamp(remap(t))} --> {stamp(stop)}\n{text}\n\n") +with open(cast, "w") as out: + out.write(json.dumps(header) + "\n") + for e in events: + out.write(json.dumps(e) + "\n") +print(f"take {knots_old[-1]:.0f}s, cut to {last:.0f}s, plays in {last / speed:.0f}s") +EOF + +agg --cols "$COLS" --rows "$ROWS" --font-size 14 --theme monokai --idle-time-limit 30 --speed "$SPEED" --last-frame-duration 3 "$OUT/demo.cast" "$OUT/demo.gif" +ffmpeg -y -loglevel error -i "$OUT/demo.gif" -movflags faststart -pix_fmt yuv420p \ + -vf 'scale=trunc(iw/2)*2:trunc(ih/2)*2' "$OUT/demo.mp4" +echo "recorded: $OUT/demo.cast $OUT/demo.gif $OUT/demo.mp4 $SRT" diff --git a/internal/standalone/standalone.go b/internal/standalone/standalone.go index 3f199189..fed07e47 100644 --- a/internal/standalone/standalone.go +++ b/internal/standalone/standalone.go @@ -490,7 +490,9 @@ func (s *Server) seedPolicyOnFirstBoot(ctx context.Context) error { if err := controlplane.ValidatePolicyConfig(&seed); err != nil { return fmt.Errorf("invalid seed mesh policy: %w", err) } - if err := s.store.SaveMeshPolicy(ctx, seed.Roles, seed.Bindings); err != nil { + // The whole document, as POST /policies stores it: a seed file that names + // egress destinations must serve them too. + if err := s.store.SavePolicyDocument(ctx, seed.Roles, seed.Bindings, seed.Egress); err != nil { return fmt.Errorf("failed to seed mesh policy: %w", err) } return nil diff --git a/site/content/docs/use-cases/sandboxed-agent.md b/site/content/docs/use-cases/sandboxed-agent.md new file mode 100644 index 00000000..95f1536d --- /dev/null +++ b/site/content/docs/use-cases/sandboxed-agent.md @@ -0,0 +1,100 @@ +--- +title: "Sandboxed Agent" +linkTitle: "Sandboxed Agent" +weight: 5 +--- + +An agent runs in a sandbox with no route into the office: here, a GitHub +codespace. Through the mesh it reaches a model and an MCP server that listen +on loopback in the office, and `api.github.com` with a credential it never +holds. The admin decides what it may call, down to the HTTP method and path, +watches every decision, and cuts it off when done. + + + +Source: [`development/examples/sandboxed-agent/`](https://github.com/google/sam/tree/main/development/examples/sandboxed-agent). +The scenes, the narration and the recording recipe are in its +[`SCRIPT.md`](https://github.com/google/sam/blob/main/development/examples/sandboxed-agent/SCRIPT.md). + +## The idea + +An agentic application is not deterministic: the code decides at run time +which API it calls and with what, so its network behaviour cannot be +reviewed in a pull request. The usual way to give it the model, the tools +and the internal API it needs is a VPN into the corporate network, which is +slow to develop against and turns the sandbox into a door with credentials +inside. + +The mesh replaces the VPN with one identity and one policy. The agent is an +ordinary program written with the [Python SDK](../../guides/native-sdks/); +the sandbox needs outbound HTTPS to one URL and nothing else, so any sandbox +works: a container, a microVM, a codespace. What the agent can reach is +decided by name on the admin's side, and the credential for the external +API stays on a node in the office. + +## The pieces + +**One policy** ([`policy.json`](https://github.com/google/sam/blob/main/development/examples/sandboxed-agent/policy.json)). +The role `agent` may call `inference://office-llm`, `mcp://tools` and +`egress://api.github.com`, the last one only with `GET` under +`/repos/google/sam/`. The destination `api.github.com` is served by nodes +labelled `site=office` with the credential named `github-ro`. + +**One node in the office** ([`pep.yaml`](https://github.com/google/sam/blob/main/development/examples/sandboxed-agent/pep.yaml)). +A `sam-node` with the label `site=office` fronts Ollama and an MCP server on +loopback. The control plane assigns `api.github.com` to it because of the +label; the node reads the token from `<--secrets-dir>/github-ro` on every +request and presents it to GitHub. See +[egress destinations](../../guides/egress-destinations/). + +**One program in the sandbox** ([`agent.py`](https://github.com/google/sam/blob/main/development/examples/sandboxed-agent/agent.py)). +It enrolls once with a single-use token minted for the role `agent`, then +discovers each service by name and calls it: `/v1/models` and a chat +completion, an MCP tool, and three requests to GitHub of which the policy +allows one. + +## Run it yourself + +On the office machine, from `development/examples/sandboxed-agent/`: + +```bash +make ollama # the office model, in Docker +make mcp # the MCP reference server on :3001, in its own terminal +make mesh # sam-one on a public https URL; copy the API URL from the banner +make tokens URL=$URL # one token for the node, one for the agent +GITHUB_TOKEN_FILE=~/github-ro make pep URL=$URL 2>&1 | python3 audit.py +``` + +`GITHUB_TOKEN_FILE` is a fine-grained token with read access to pull +requests on `google/sam`, and nothing else; the node is the only process +that reads it. + +In the sandbox, with the SDK installed (`pip install ./sdk/python`) and the +agent token the admin handed you: + +```bash +export SAM_CONTROL_PLANE_URL=$URL SAM_BOOTSTRAP_TOKEN_PATH=./agent-token +python agent.py # every step, or one of: models, ask, tool, github GET /user +``` + +Then, back in the office, `make revoke URL=$URL` takes the destination off +the mesh and `make ban URL=$URL PEER=` cuts the agent off. + +## What to notice + +- The request GitHub receives carries the office's token and none of the + agent's headers. The sandbox never had a credential to leak. +- `POST /repos/google/sam/pulls` and `GET /user` are answered `403` with + `Proxy-Status: sam-node; error=http_request_denied` by the node, before + GitHub hears of them. Each is one `DENY` line in the node's log with the + peer, the role, the method and the path. +- Removing the `egress` entry from the policy withdraws the destination on + the node within its sync jitter (the example runs with + `--control-plane-sync-interval 30s`); the same request then finds no + service. Banning the peer disconnects it and no router admits it again. +- Grants live in a member's credential until it is refreshed (24 hours by + default, `--control-plane-biscuit-ttl`). Removing a service from a role + takes effect at that refresh; withdrawing a destination or banning a + member takes effect at once, which is why the demo uses those two. diff --git a/site/static/demo-sandboxed-agent.mp4 b/site/static/demo-sandboxed-agent.mp4 new file mode 100644 index 00000000..a101c718 Binary files /dev/null and b/site/static/demo-sandboxed-agent.mp4 differ diff --git a/tests/integration/standalone_test.go b/tests/integration/standalone_test.go index c859bff0..3222071d 100644 --- a/tests/integration/standalone_test.go +++ b/tests/integration/standalone_test.go @@ -32,6 +32,7 @@ import ( "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/encoding/protojson" ) // TestStandaloneNodeJoin pins the sam-one first-boot CUJ end to end: one @@ -216,6 +217,63 @@ func TestStandaloneNoJoinToken(t *testing.T) { } } +// TestStandalonePolicyFileSeedsEgress pins that a first-boot --policy-file +// is stored whole: the egress destinations it names are served, the same as +// when the document arrives through POST /policies. +func TestStandalonePolicyFileSeedsEgress(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + policyFile := filepath.Join(t.TempDir(), "policy.json") + seed := `{ + "roles": [ + {"name": "sam:role:node", "allowed_targets": ["*"], "allowed_labels": ["site=office"]}, + {"name": "agent", "allowed_services": ["egress://api.github.com"], "allowed_targets": ["*"], + "http": [{"service": "egress://api.github.com", "methods": ["GET"], "paths": ["/repos/acme/*"]}]} + ], + "egress": [{"name": "api.github.com", "credential": "github-ro", "served_by": ["site=office"]}] +}` + if err := os.WriteFile(policyFile, []byte(seed), 0o600); err != nil { + t.Fatal(err) + } + srv, err := standalone.New(standalone.Options{BindAddress: "127.0.0.1:0", DataDir: t.TempDir(), PolicyFile: policyFile}) + if err != nil { + t.Fatalf("failed to create standalone server: %v", err) + } + if err := srv.Start(ctx); err != nil { + t.Fatalf("failed to start standalone server: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+srv.Addr()+"/admin/policy", nil) + if err != nil { + t.Fatalf("GET /admin/policy request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+srv.AdminToken()) + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + t.Fatalf("GET /admin/policy: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("GET /admin/policy body: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/policy = %s %s", resp.Status, body) + } + var stored api.PolicyConfig + if err := protojson.Unmarshal(body, &stored); err != nil { + t.Fatalf("GET /admin/policy body: %v\n%s", err, body) + } + if len(stored.Egress) != 1 || stored.Egress[0].GetName() != "api.github.com" || stored.Egress[0].GetCredential() != "github-ro" { + t.Fatalf("seeded egress = %v, want api.github.com with credential github-ro", stored.Egress) + } + if len(stored.Roles) != 2 { + t.Fatalf("seeded roles = %d, want 2", len(stored.Roles)) + } +} + // newStandaloneTestNode returns a started, unenrolled loopback node. func newStandaloneTestNode(t *testing.T, ctx context.Context) *node.SamNode { t.Helper()