From 87956d5844a2d2ca1f05e2c7848f4108aaa139d7 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sat, 3 Oct 2026 11:41:51 +0000 Subject: [PATCH 01/13] Phase 1: remove sam-box, nano-init, and legacy agent identity machinery --- .github/dependabot.yml | 1 - .github/k8s/sam-box-canary-template.yaml | 218 --- .github/workflows/deploy.yaml | 87 +- AGENTS.md | 8 +- Dockerfile.nano-init | 14 - Dockerfile.sam-box | 13 - Makefile | 18 +- api/agent.go | 167 -- api/agent_namespace_test.go | 123 -- api/agent_test.go | 112 -- api/datalog.go | 142 -- api/egress_test.go | 13 - api/names.go | 155 +- api/names_test.go | 127 +- api/network.go | 11 - api/policy_rules.go | 7 - api/policy_rules_test.go | 6 +- api/sam.pb.go | 1357 +++-------------- api/sam.proto | 144 +- api/validation.go | 3 - cmd/nano-init/.gitignore | 2 - cmd/nano-init/README.md | 88 -- cmd/nano-init/copy.go | 38 - cmd/nano-init/go.mod | 22 - cmd/nano-init/go.sum | 30 - cmd/nano-init/ingress.go | 196 --- cmd/nano-init/ingress_test.go | 237 --- cmd/nano-init/isolation.go | 148 -- cmd/nano-init/isolation_test.go | 173 --- cmd/nano-init/main.go | 422 ----- cmd/nano-init/main_test.go | 100 -- cmd/nano-init/namespaces.go | 266 ---- cmd/nano-init/namespaces_test.go | 188 --- cmd/nano-init/vsock.go | 103 -- cmd/nano-init/vsock_test.go | 186 --- cmd/sam-box/main.go | 261 ---- development/examples/agent-harness/Dockerfile | 27 - development/examples/agent-harness/README.md | 73 - development/examples/agent-harness/agent.py | 186 --- .../examples/agent-harness/bundle.yaml | 27 - .../examples/agent-harness/requirements.txt | 12 - hack/gen-sdk-datalog/main.go | 2 - hack/lint.sh | 3 - internal/console/public/index.html | 1 - internal/controlplane/server.go | 10 +- internal/controlplane/server_test.go | 22 +- internal/identity/attenuation_test.go | 4 +- internal/identity/biscuit.go | 10 - internal/node/agent.go | 107 -- internal/node/agent_metrics.go | 83 - internal/node/agent_metrics_test.go | 104 -- internal/node/agent_namespace_test.go | 150 -- internal/node/agent_test.go | 96 -- internal/node/egress.go | 7 +- internal/node/egress_route_test.go | 9 +- internal/node/egress_test.go | 14 +- internal/node/mcp.go | 43 +- internal/node/middleware.go | 39 - internal/node/node.go | 4 - internal/node/sidecar.go | 10 - internal/sambox/bundle.go | 129 -- internal/sambox/bundle_test.go | 155 -- internal/sambox/capsule.go | 136 -- internal/sambox/connect.go | 404 ----- internal/sambox/connect_test.go | 638 -------- internal/sambox/credential.go | 97 -- internal/sambox/credential_test.go | 247 --- internal/sambox/dial.go | 152 -- internal/sambox/dial_test.go | 123 -- internal/sambox/entrypoint.go | 103 -- internal/sambox/entrypoint_test.go | 265 ---- internal/sambox/ingress.go | 255 ---- internal/sambox/ingress_test.go | 152 -- internal/sambox/listen.go | 69 - internal/sambox/listen_test.go | 126 -- internal/sambox/mesh.go | 204 --- internal/sambox/mesh_test.go | 226 --- internal/sambox/metrics.go | 90 -- internal/sambox/metrics_server.go | 60 - internal/sambox/metrics_test.go | 145 -- internal/sambox/route.go | 188 --- internal/sambox/route_test.go | 179 --- internal/storage/mesh_policy_test.go | 1 - internal/storage/sql_store.go | 7 - sdk/js/src/authorizer.test.ts | 21 +- sdk/js/src/authorizer.ts | 12 - sdk/js/src/conformance.ts | 2 +- sdk/js/src/credential.ts | 6 +- sdk/js/src/gen/datalog.ts | 9 - sdk/js/src/gen/sam_pb.ts | 420 +---- sdk/js/src/libp2p-http-node.ts | 2 - sdk/js/src/libp2p-http.ts | 18 +- sdk/js/src/mcp.ts | 2 - sdk/js/src/mesh.test.ts | 3 +- sdk/js/src/mesh.ts | 6 +- sdk/js/src/session.ts | 9 +- sdk/python/src/agent_mesh/_gen/datalog.json | 9 - sdk/python/src/agent_mesh/_proto/sam_pb2.py | 202 ++- sdk/python/src/agent_mesh/_proto/sam_pb2.pyi | 134 +- sdk/python/src/agent_mesh/authorizer.py | 10 - sdk/python/src/agent_mesh/conformance.py | 2 +- sdk/python/src/agent_mesh/credential.py | 6 +- sdk/python/src/agent_mesh/httpx_transport.py | 4 +- sdk/python/src/agent_mesh/libp2p_http.py | 23 +- sdk/python/src/agent_mesh/mesh.py | 6 +- sdk/python/src/agent_mesh/session.py | 6 +- sdk/python/tests/test_authorizer.py | 18 +- sdk/python/tests/test_mesh.py | 3 +- sts.md | 692 +++++++++ tests/e2e/agent_sandbox.bats | 122 -- tests/e2e/canary_manifests.bats | 92 -- tests/e2e/docker/Dockerfile.sam-runtime | 40 - tests/e2e/microvm_sandbox.bats | 133 -- tests/integration/agent_ingress_test.go | 229 --- tests/integration/agent_policy_test.go | 240 --- tests/integration/egress_test.go | 9 - tests/integration/minimal_helpers_test.go | 52 +- tests/integration/policy_grants_test.go | 11 +- tests/integration/sambox_test.go | 248 --- tests/integration/sandbox_boundary_test.go | 371 ----- tests/integration/sandbox_ingress_test.go | 197 --- tests/integration/sandbox_namespaces_test.go | 194 --- tests/integration/sdk_enroll_test.go | 4 +- tests/ui/dev.sh | 2 +- 124 files changed, 1181 insertions(+), 12768 deletions(-) delete mode 100644 .github/k8s/sam-box-canary-template.yaml delete mode 100644 Dockerfile.nano-init delete mode 100644 Dockerfile.sam-box delete mode 100644 api/agent.go delete mode 100644 api/agent_namespace_test.go delete mode 100644 api/agent_test.go delete mode 100644 cmd/nano-init/.gitignore delete mode 100644 cmd/nano-init/README.md delete mode 100644 cmd/nano-init/copy.go delete mode 100644 cmd/nano-init/go.mod delete mode 100644 cmd/nano-init/go.sum delete mode 100644 cmd/nano-init/ingress.go delete mode 100644 cmd/nano-init/ingress_test.go delete mode 100644 cmd/nano-init/isolation.go delete mode 100644 cmd/nano-init/isolation_test.go delete mode 100644 cmd/nano-init/main.go delete mode 100644 cmd/nano-init/main_test.go delete mode 100644 cmd/nano-init/namespaces.go delete mode 100644 cmd/nano-init/namespaces_test.go delete mode 100644 cmd/nano-init/vsock.go delete mode 100644 cmd/nano-init/vsock_test.go delete mode 100644 cmd/sam-box/main.go delete mode 100644 development/examples/agent-harness/Dockerfile delete mode 100644 development/examples/agent-harness/README.md delete mode 100644 development/examples/agent-harness/agent.py delete mode 100644 development/examples/agent-harness/bundle.yaml delete mode 100644 development/examples/agent-harness/requirements.txt delete mode 100644 internal/node/agent.go delete mode 100644 internal/node/agent_metrics.go delete mode 100644 internal/node/agent_metrics_test.go delete mode 100644 internal/node/agent_namespace_test.go delete mode 100644 internal/node/agent_test.go delete mode 100644 internal/sambox/bundle.go delete mode 100644 internal/sambox/bundle_test.go delete mode 100644 internal/sambox/capsule.go delete mode 100644 internal/sambox/connect.go delete mode 100644 internal/sambox/connect_test.go delete mode 100644 internal/sambox/credential.go delete mode 100644 internal/sambox/credential_test.go delete mode 100644 internal/sambox/dial.go delete mode 100644 internal/sambox/dial_test.go delete mode 100644 internal/sambox/entrypoint.go delete mode 100644 internal/sambox/entrypoint_test.go delete mode 100644 internal/sambox/ingress.go delete mode 100644 internal/sambox/ingress_test.go delete mode 100644 internal/sambox/listen.go delete mode 100644 internal/sambox/listen_test.go delete mode 100644 internal/sambox/mesh.go delete mode 100644 internal/sambox/mesh_test.go delete mode 100644 internal/sambox/metrics.go delete mode 100644 internal/sambox/metrics_server.go delete mode 100644 internal/sambox/metrics_test.go delete mode 100644 internal/sambox/route.go delete mode 100644 internal/sambox/route_test.go create mode 100644 sts.md delete mode 100644 tests/e2e/agent_sandbox.bats delete mode 100644 tests/e2e/docker/Dockerfile.sam-runtime delete mode 100644 tests/e2e/microvm_sandbox.bats delete mode 100644 tests/integration/agent_ingress_test.go delete mode 100644 tests/integration/agent_policy_test.go delete mode 100644 tests/integration/sambox_test.go delete mode 100644 tests/integration/sandbox_boundary_test.go delete mode 100644 tests/integration/sandbox_ingress_test.go delete mode 100644 tests/integration/sandbox_namespaces_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f58ad57f..8e7787b3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,7 +17,6 @@ updates: - package-ecosystem: "gomod" directories: - "/" - - "/cmd/nano-init" schedule: interval: "weekly" cooldown: diff --git a/.github/k8s/sam-box-canary-template.yaml b/.github/k8s/sam-box-canary-template.yaml deleted file mode 100644 index 476ab62a..00000000 --- a/.github/k8s/sam-box-canary-template.yaml +++ /dev/null @@ -1,218 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: sam-canary-box-config-${ENV_NAME} - namespace: sam-canary-${ENV_NAME} -data: - sam-node.yaml: | - version: "v1alpha1" - attenuation: - policies: [] - checks: [] - rules: [] - services: [] ---- -# The resolver the sandbox uses, which is nano-init's own on the tun. Supplied -# here so nano-init does not have to mount it: a pod's resolv.conf is shared by -# every container, and replacing it from inside needs a bind mount that -# containerd's default AppArmor profile denies. -apiVersion: v1 -kind: ConfigMap -metadata: - name: sam-canary-sandbox-resolv-${ENV_NAME} - namespace: sam-canary-${ENV_NAME} -data: - resolv.conf: | - nameserver 100.127.255.253 ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: sam-box-sa - namespace: sam-canary-${ENV_NAME} ---- -# Canary for the sandbox datapath: an agent holding no credential reaches an -# allowlisted destination through the boundary, and nothing else. -# -# The node enrols and owns the mesh identity; sam-box consumes it over the API -# socket and serves the sandbox. All that is shared between them is the socket, -# which is the point of the arrangement. -# -# The agent is a real sandbox rather than a client that has been told where to -# find a proxy. Every container in a pod shares one network namespace, so -# nano-init makes its own with --create-namespaces and gives itself the only -# route out; the agent then speaks ordinary HTTP to ordinary names, and the -# fact that it reaches anything at all is the assertion. A curl pointed at -# --proxy would prove the boundary works for a client that chose to use it, -# which is the one case nobody needs proving. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: box-canary-${ENV_NAME} - namespace: sam-canary-${ENV_NAME} -spec: - replicas: 1 - selector: - matchLabels: - app: box-canary-${ENV_NAME} - template: - metadata: - labels: - app: box-canary-${ENV_NAME} - sam-canary: "true" - spec: - serviceAccountName: sam-box-sa - # nano-init ships as its own image with no shell in it, so the binary is - # handed to the agent container rather than the agent image being built - # around it. Any image with a client in it can be a sandbox this way. - initContainers: - - name: nano-init - image: ghcr.io/google/sam-nano-init:${IMAGE_TAG} - args: ["copy", "/sandbox/nano-init"] - volumeMounts: - - name: sandbox - mountPath: /sandbox - containers: - - name: sam-node - image: ghcr.io/google/sam-node:${IMAGE_TAG} - args: - - "run" - - "--config=/etc/sam/sam-node.yaml" - - "--control-plane=http://sam-control-plane-${ENV_NAME}.${NAMESPACE}.svc.cluster.local:8080" - - "--insecure-control-plane" - - "--jwt-path=/var/run/secrets/tokens/sam-token" - # Socket only: with no TCP listener there is no API token to leak, and - # the socket's permissions are the credential. - - "--bind-addr=" - - "--socket-path=/var/run/sam/node.sock" - # The one TCP port, and it carries nothing an API token would gate. - - "--metrics-addr=0.0.0.0:9090" - ports: - - containerPort: 9090 - name: metrics - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 256Mi - volumeMounts: - - name: config-volume - mountPath: /etc/sam - - name: sam-token - mountPath: /var/run/secrets/tokens - readOnly: true - - name: sam-uds - mountPath: /var/run/sam - - name: sam-box - image: ghcr.io/google/sam-box:${IMAGE_TAG} - args: - - "run" - - "--socket=/var/run/sam/agent.sock" - - "--sidecar-socket=/var/run/sam/node.sock" - - "--egress-allow=example.com" - - "--log-level=debug" - - "--metrics-addr=0.0.0.0:9091" - ports: - - containerPort: 9091 - name: box-metrics - resources: - requests: - cpu: 20m - memory: 32Mi - limits: - cpu: 100m - memory: 128Mi - volumeMounts: - - name: sam-uds - mountPath: /var/run/sam - # No securityContext: creating the namespaces needs CAP_SYS_ADMIN and the - # tun needs CAP_NET_ADMIN, and a user namespace supplies both over the - # namespaces it owns. Granting CAP_SYS_ADMIN alone would be worse than - # granting nothing -- the namespace would be created and the tun would - # then fail for want of the other one. - - name: agent - image: alpine/curl:8.12.1 - # The boundary socket is 0600, owned by the uid sam-box runs as, and - # that is the access control rather than an accident of packaging. A - # user namespace maps exactly one uid, so the sandbox has to be that - # uid: as root it would map 0 and hold no privilege over a file owned - # by 65532, and the connection would be refused. - # - # Nothing else is granted: no capabilities, not privileged, and no - # AppArmor change. The sandbox needs none because the resolv.conf it - # would otherwise have to bind-mount is mounted for it below, and that - # bind was the only thing containerd's default profile denied. - securityContext: - runAsUser: 65532 - runAsGroup: 65532 - command: - - "/sandbox/nano-init" - - "run" - - "--create-namespaces" - - "/var/run/sam/agent.sock" - - "sh" - - "-c" - - | - echo "the sandbox's interfaces (expect lo and tun0 only): $(ip -o link show | cut -d: -f2 | tr -d ' ' | paste -sd,)" - while true; do - echo "allowlisted destination (expect 200): $(curl -s -o /dev/null -w '%{http_code}' http://example.com/)" - echo "the node's own API (expect 403): $(curl -s -o /dev/null -w '%{http_code}' http://mesh.sam.alt/sam/service/discover)" - curl -s -o /dev/null http://blocked.example/ && echo "unlisted destination was NOT refused" || echo "unlisted destination refused (expected)" - sleep 30 - done - resources: - requests: - cpu: 10m - memory: 16Mi - limits: - cpu: 50m - memory: 64Mi - volumeMounts: - - name: sam-uds - mountPath: /var/run/sam - - name: sandbox - mountPath: /sandbox - # A new mount namespace copies the mount table, not the files behind - # it, so the private resolv.conf is a bind mount over a real file that - # has to be created somewhere. - - name: scratch - mountPath: /tmp - # The sandbox's own resolver, supplied by the kubelet rather than - # mounted by nano-init. A pod's resolv.conf is one file shared by every - # container in it, so the sandbox needs one of its own; having the - # kubelet mount it means nano-init never has to, which is what keeps - # this container free of any AppArmor exception. - - name: resolv - mountPath: /etc/resolv.conf - subPath: resolv.conf - # A bind mount is enough. The device cgroup does not deny this one, so - # there is no device plugin involved; what the tun needs is the - # capability, and the user namespace supplies that. - - name: tun - mountPath: /dev/net/tun - volumes: - - name: sam-uds - emptyDir: {} - - name: sandbox - emptyDir: {} - - name: scratch - emptyDir: {} - - name: resolv - configMap: - name: sam-canary-sandbox-resolv-${ENV_NAME} - - name: tun - hostPath: - path: /dev/net/tun - type: CharDevice - - name: config-volume - configMap: - name: sam-canary-box-config-${ENV_NAME} - - name: sam-token - projected: - sources: - - serviceAccountToken: - path: sam-token - expirationSeconds: 3600 - audience: "sam-control-plane-audience" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 57bb8637..90804bb3 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -119,50 +119,6 @@ jobs: tags: ${{ steps.meta-node.outputs.tags }} labels: ${{ steps.meta-node.outputs.labels }} - - name: Extract metadata for nano-init - id: meta-nano-init - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ${{ env.REGISTRY }}/google/sam-nano-init - tags: | - type=ref,event=branch - type=ref,event=tag - type=raw,value=${{ github.sha }} - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - - - name: Build and push nano-init image - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: Dockerfile.nano-init - platforms: ${{ github.ref_type == 'tag' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: true - tags: ${{ steps.meta-nano-init.outputs.tags }} - labels: ${{ steps.meta-nano-init.outputs.labels }} - - - name: Extract metadata for sam-box - id: meta-sambox - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ${{ env.REGISTRY }}/google/sam-box - tags: | - type=ref,event=branch - type=ref,event=tag - type=raw,value=${{ github.sha }} - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - - - name: Build and push sam-box image - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: Dockerfile.sam-box - platforms: ${{ github.ref_type == 'tag' && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - push: true - tags: ${{ steps.meta-sambox.outputs.tags }} - labels: ${{ steps.meta-sambox.outputs.labels }} - - name: Extract metadata for sam-console id: meta-console uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -486,13 +442,12 @@ jobs: -d '{ "roles": [ {"name": "sam-canary", "allowed_services": ["*"], "allowed_targets": ["*"]}, - {"name": "sam:role:sambox", "allowed_services": ["*"], "allowed_targets": ["*"]}, {"name": "sam:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, {"name": "sam:role:node"}, {"name": "public-mesh", "allowed_services": ["*"], "allowed_targets": ["*"]} ], "bindings": [ - {"role": "sam-canary", "members": ["user:system:serviceaccount:sam-canary-'"${ENV_NAME}"':sam-node-sa", "user:system:serviceaccount:sam-canary-'"${ENV_NAME}"':sam-box-sa"]}, + {"role": "sam-canary", "members": ["user:system:serviceaccount:sam-canary-'"${ENV_NAME}"':sam-node-sa"]}, {"role": "sam:role:router", "members": ["group:routers", "user:system:serviceaccount:'"${NAMESPACE}"':sam-router-sa"]}, {"role": "sam:role:node", "members": ["sam:system:authenticated"]}, {"role": "public-mesh", "members": ["sam:system:authenticated"]} @@ -620,46 +575,6 @@ jobs: --external-addr=/ip4/${EXTERNAL_IP}/udp/4501/quic-v1 \ --dht-provider-addr-ttl='"${DHT_PROVIDER_ADDR_TTL}"'' - - name: Deploy SAM Box Canary - env: - VAR_ENV_NAME: ${{ vars.ENV_NAME }} - VAR_IMAGE_TAG: ${{ env.IMAGE_TAG }} - run: | - print_rollout_diagnostics() { - local namespace="$1" - local deployment="$2" - local selector="$3" - - echo "Collecting diagnostics for deployment/${deployment} in namespace ${namespace}..." - - kubectl describe deployment/${deployment} -n ${namespace} || true - kubectl get pods -n ${namespace} -l "${selector}" -o wide || true - kubectl describe pods -n ${namespace} -l "${selector}" || true - kubectl get events -n ${namespace} --sort-by=.lastTimestamp || true - - for pod in $(kubectl get pods -n ${namespace} -l "${selector}" -o name 2>/dev/null); do - echo "==== Describe ${pod} ====" - kubectl describe -n ${namespace} "${pod}" || true - - for container in $(kubectl get -n ${namespace} "${pod}" -o jsonpath='{.spec.containers[*].name}' 2>/dev/null); do - echo "==== Logs for ${pod} container ${container} ====" - kubectl logs -n ${namespace} "${pod#pod/}" -c "${container}" --tail=-1 || true - done - done - } - - export ENV_NAME="${VAR_ENV_NAME}" - export CANARY_NAMESPACE="sam-canary-${ENV_NAME}" - export NAMESPACE="sam-${ENV_NAME}" - export IMAGE_TAG="${VAR_IMAGE_TAG}" - - envsubst '${ENV_NAME} ${NAMESPACE} ${IMAGE_TAG}' < .github/k8s/sam-box-canary-template.yaml | kubectl apply -f - - kubectl rollout status deployment/box-canary-${ENV_NAME} -n ${CANARY_NAMESPACE} --timeout=120s || { - echo "sam-box Canary Deployment failed!" - print_rollout_diagnostics "${CANARY_NAMESPACE}" "box-canary-${ENV_NAME}" "app=box-canary-${ENV_NAME}" - exit 1 - } - - name: Deploy OpenClaw Canary env: VAR_ENV_NAME: ${{ vars.ENV_NAME }} diff --git a/AGENTS.md b/AGENTS.md index 9a531302..0b1aec10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,21 +5,19 @@ You are an expert software engineering assistant helping to develop, maintain, a ## 1. Architecture & Component Independence * **Decoupled Architecture:** The `sam-control-plane`, `sam-router` and `sam-node` components are strictly independent. They must not share internal state or tightly couple their logic. * **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`. -* **One schema, two encodings:** every request and response on any SAM surface is a message in `api/sam.proto`. The *mesh protocol* — anything a mesh component speaks (node, router, `sam-box`, the agent connector, the SDKs under `sdk/`): enrollment, refresh, keys, leases, auth streams, policy sync — is binary protobuf (`application/x-protobuf`). The *operator plane* — what humans, the web console and admin CLIs call (`/admin/*`, `/users/*`, `POST /policies`) — is protojson of the same messages, with `UseProtoNames` and unknown fields rejected. Never define a wire shape as a Go struct with `json` tags, an anonymous struct or `map[string]any`, and never serialize an `internal/storage` (or any other internal) type onto either surface. Clients in `cmd/`, `internal/console` and `sdk/` import `api/` or its generated bindings only. +* **One schema, two encodings:** every request and response on any SAM-defined surface is a message in `api/sam.proto`. The *mesh protocol* — anything a mesh component speaks (node, router, the SDKs under `sdk/`): enrollment, refresh, keys, leases, auth streams, policy sync, token exchange (`/token/exchange`), and outbound border JWT minting (`/sts/token`) — is binary protobuf (`application/x-protobuf`). The *operator plane* — what humans, the web console and admin CLIs call (`/admin/*`, `/users/*`, `POST /policies`) — is protojson of the same messages, with `UseProtoNames` and unknown fields rejected. Never define a wire shape on a SAM-defined surface as a Go struct with `json` tags, an anonymous struct or `map[string]any`, and never serialize an `internal/storage` (or any other internal) type onto either surface. The sole exceptions are external standard protocols for unmodified third-party clients and cloud federation: OAuth 2.1 / RFC 8693 / RFC 7009 / RFC 9728 (`POST /oauth/token`, `POST /oauth/revoke`, `/oauth/authorize`, `/.well-known/*`, `/jwks`), Envoy `ext_authz` / `ext_proc`, MCP JSON-RPC (`/mcp`), OpenAI `/v1/*`, and A2A `/.well-known/agent-card.json`. Clients in `cmd/`, `internal/console` and `sdk/` import `api/` or its generated bindings only. * **Instants are `google.protobuf.Timestamp`, named `*_time`** (`expire_time`, `sign_time`, `event_time`), never `int64` seconds or milliseconds. `Timestamp` has fixed units, an explicit unset, renders as RFC 3339 in protojson (an `int64` renders as a quoted decimal string), and has a first-class conversion in Go, JavaScript, Python and Dart. A receiver treats an unset instant as invalid, never as the epoch. The one exception is a proof-of-possession value: it is the number that appears in the signed text (`sam:::`), so it stays an `int64` named `challenge_unix_ms`, the unit in the name. What a member persists between runs is also a message here (`MemberCredential`), so a state directory written by one implementation loads in another. * **Datalog text is the policy contract:** the control plane renders the mesh policy as Datalog rules (`PolicyConfigGetResponse.datalog_rules`); every member, in any language, adds that text to its authorizer and none derives rules from roles and bindings itself. Datalog must stay in the form every Biscuit implementation parses: a predicate carries at least one term (presence-only facts are `name(true)`, see `api.MarkerFact`). * **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/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. +* **Task-Scoped Authorization & Safe Biscuit Attenuation (`tar_block`):** SAM acts as the Authority, Policy Decision Point (PDP), and Task-Scoped Credential Layer (`sts.md`). Never add Datalog rules or checks to non-authority Biscuit blocks (`block_idx >= 1`). Appended blocks must contain 0 rules, 0 checks, and at most 1 `tar_block("")` fact encoding a serialized `api.TaskAuthorizationRule`. Verifiers enforce the intersection of Block 0 Datalog RBAC and every appended `TaskAuthorizationRule` in Go, TypeScript, and Python. +* **Two-Token Model & Complementary Gateway/PEP Integration:** Inside the mesh, credentials are Biscuits; at both borders, credentials are standard JWTs (`sts.md`). Inbound platform credentials (OIDC, K8s SA, SPIFFE JWT-SVID) exchange into delegated Biscuits (`POST /token/exchange`); outbound verified Biscuits mint short-lived ES256 JWTs at the control plane (`POST /sts/token`) that the egress node exchanges at cloud STS endpoints (`CloudTokenExchanger`). `sam-node` integrates with existing gateways (`agentgateway`, Istio, Envoy) via Envoy `ext_authz` / `ext_proc` and RFC 8693 `/oauth/token`, while native SDKs (`sdk/js`, `sdk/python`) attenuate and seal Biscuits in memory. * **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. * **Zero Trust:** Enforce a Zero Trust architecture. Assume no implicit trust between nodes, control planes, routers, or external actors. All data passing through the API must be authenticated, authorized, and validated. * **Simple UX:** Maintain a very simple User Experience. Configuration, CLI usage, and error messages must be intuitive, minimal, and explicitly clear. ## 2. Dependency Management (Strict Constraint) * **You are forbidden from suggesting any code that requires a new entry in `go.mod` unless you explicitly ask for my permission first.** * If a task can be solved using the existing dependencies or the Go standard library, you must choose that path even if it requires more lines of code. -* Guest-only dependencies (e.g. the userspace TCP stack in `cmd/nano-init`) live in that command's own Go module so the root `go.mod` never carries them. Follow that pattern for anything that only runs inside a sandbox image. ## 3. Testing Best Practices Enforce strict modularity in testing. The repository uses a defined testing pyramid (Unit, Integration, and E2E via Bats). You must adhere to the following testing philosophy: diff --git a/Dockerfile.nano-init b/Dockerfile.nano-init deleted file mode 100644 index 8776979c..00000000 --- a/Dockerfile.nano-init +++ /dev/null @@ -1,14 +0,0 @@ -# Stage 1: Build -FROM golang:1.27.1@sha256:3680233e3204827fbdc66088528ae6d4b3d034f51d03a99d454f6de034888244 AS builder -RUN apt-get update && apt-get install -y gcc libc-dev -WORKDIR /app -COPY cmd/nano-init/go.mod cmd/nano-init/go.sum ./ -RUN go mod download -COPY cmd/nano-init/ ./ -RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-s -w" -o nano-init . - -# Stage 2: Final -FROM gcr.io/distroless/base:nonroot@sha256:0896741ba5bafd3ac87ea025a5f578952f2d238ddc3614cb368acc983a687aa2 -COPY --from=builder --chown=nonroot:nonroot /app/nano-init / -USER nonroot:nonroot -ENTRYPOINT ["/nano-init"] diff --git a/Dockerfile.sam-box b/Dockerfile.sam-box deleted file mode 100644 index 4885d2b8..00000000 --- a/Dockerfile.sam-box +++ /dev/null @@ -1,13 +0,0 @@ -# Stage 1: Build -FROM golang:1.27.1@sha256:3680233e3204827fbdc66088528ae6d4b3d034f51d03a99d454f6de034888244 AS builder -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o sam-box ./cmd/sam-box - -# Stage 2: Final -FROM gcr.io/distroless/static:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 -COPY --from=builder --chown=nonroot:nonroot /app/sam-box / -USER nonroot:nonroot -ENTRYPOINT ["/sam-box"] diff --git a/Makefile b/Makefile index 9884bbf4..1bed76cd 100644 --- a/Makefile +++ b/Makefile @@ -30,12 +30,8 @@ build: go build -v -ldflags "$(VERSION_LDFLAGS)" -o "$(OUT_DIR)/sam-router" ./cmd/sam-router go build -v -ldflags "$(VERSION_LDFLAGS)" -o "$(OUT_DIR)/sam-one" ./cmd/sam-one go build -v -o "$(OUT_DIR)/mcp-client" ./cmd/mcp-client - go build -v -o "$(OUT_DIR)/sam-box" ./cmd/sam-box go build -v -o "$(OUT_DIR)/sam-bench" ./cmd/sam-bench go build -v -o "$(OUT_DIR)/sam-console" ./cmd/sam-console - # nano-init is a separate module: it carries a userspace TCP stack, which - # has no business in the dependency graph every other binary builds from. - go -C cmd/nano-init build -v -o "$(OUT_DIR)/nano-init" . .PHONY: mobile-ffi-host mobile-ffi-android mobile-ffi-android-x86_64 mobile-ffi-ios mobile-ffi mobile-app-apk mobile-app-apk-emulator mobile-app-bundle @@ -162,7 +158,6 @@ testnet: test: CGO_ENABLED=1 go test -v -race -count 1 $(if $(WHAT),-run $(WHAT)) ./... - CGO_ENABLED=1 go -C cmd/nano-init test -race -count 1 $(if $(WHAT),-run $(WHAT)) ./... e2e-test: build docker-build bats -j 10 --verbose-run $(if $(WHAT),--filter "$(WHAT)") tests/e2e/ @@ -275,18 +270,9 @@ docker-build-node: docker-build-mock-oidc: docker build --load -t sam-mock-oidc:local -f tests/e2e/docker/Dockerfile.mock-oidc . -docker-build-e2e-runtime: - docker build --load -t sam-e2e-runtime:local -f tests/e2e/docker/Dockerfile.sam-runtime . - -docker-build-nano-init: - docker build --load -t sam-nano-init:local -f Dockerfile.nano-init . - -docker-build-sam-box: - docker build --load -t sam-box:local -f Dockerfile.sam-box . - docker-build-sam-console: docker build --load -t sam-console:local -f Dockerfile.sam-console . -docker-build: docker-build-control-plane docker-build-router docker-build-node docker-build-mock-oidc docker-build-e2e-runtime docker-build-nano-init docker-build-sam-box docker-build-sam-console +docker-build: docker-build-control-plane docker-build-router docker-build-node docker-build-mock-oidc docker-build-sam-console -.PHONY: docker-build-control-plane docker-build-router docker-build-node docker-build-mock-oidc docker-build-e2e-runtime docker-build-nano-init docker-build-sam-box docker-build-sam-console docker-build +.PHONY: docker-build-control-plane docker-build-router docker-build-node docker-build-mock-oidc docker-build-sam-console docker-build diff --git a/api/agent.go b/api/agent.go deleted file mode 100644 index 1b79b60e..00000000 --- a/api/agent.go +++ /dev/null @@ -1,167 +0,0 @@ -// 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. - -package api - -import ( - "fmt" - "strings" -) - -// ============================================================================ -// Agent Principals -// ============================================================================ -// -// An agent is a mesh principal in its own right: policy is written about the -// agent, not about whichever node happens to be hosting it, and the identifier -// survives an agent being suspended on one host and resumed on another. -// -// Agent identifiers are a SAM convention, the way "user", "email" and "group" -// facts are. External identity systems are not expected to adopt it; a -// connector translates into it at admission, exactly as translateClaimsToFacts -// translates OIDC claims at node enrollment. That translation must be total, -// injective (or tenants collide), hierarchy-preserving (or wildcard policy -// stops being expressible and operators are forced back to enumeration), and -// auditable. -// -// spiffe://acme.example/prod/reviewer-7 -> agent:reviewer-7.prod.acme.example -// -// The dotted, most-specific-first shape is not cosmetic. BuildTargetDatalogFact -// compiles "*.acme.example" into a suffix fact that keeps the leading dot and -// "acme.*" into a prefix fact that keeps the trailing dot, so wildcards are -// already anchored on label boundaries: "evil-acme.example" cannot match -// "*.acme.example". A slash-separated identifier would need new fact kinds and -// new matching code, and would reintroduce the boundary bug this avoids. - -const ( - // MaxAgentIDLen bounds an agent identifier, matching the DNS name limit it - // is shaped after. - MaxAgentIDLen = 253 - - // MaxAgentLabelLen bounds one dot-separated label of an agent identifier. - MaxAgentLabelLen = 63 -) - -// ValidateAgentID checks an agent identifier: the value part of an "agent:" -// member or target, without the prefix. -// -// The rules exist to keep prefix and suffix policy safe and unambiguous: -// lowercase because the shape is DNS-shaped and DNS is case-insensitive, so -// two identifiers differing only in case must not be two principals; at least -// two labels because the rightmost labels are the authority that keeps -// identifiers from colliding across tenants; and no wildcards, because a -// wildcard is a policy pattern and never an identity. -func ValidateAgentID(id string) error { - if id == "" { - return fmt.Errorf("agent id cannot be empty") - } - if len(id) > MaxAgentIDLen { - return fmt.Errorf("agent id %q exceeds %d characters", id, MaxAgentIDLen) - } - if id != strings.ToLower(id) { - return fmt.Errorf("agent id %q must be lowercase", id) - } - if strings.ContainsAny(id, "*:/ ") { - return fmt.Errorf("agent id %q must not contain a wildcard, a scheme separator, a path or a space", id) - } - - labels := strings.Split(id, ".") - if len(labels) < 2 { - return fmt.Errorf("agent id %q must be qualified by an authority, e.g. reviewer-7.prod.acme.example", id) - } - for _, label := range labels { - if err := validateAgentLabel(label); err != nil { - return fmt.Errorf("agent id %q: %w", id, err) - } - } - return nil -} - -// validateAgentLabel applies the same character rules as dnsNameRegex uses for -// service names, so an agent identifier and a service name are validated alike. -func validateAgentLabel(label string) error { - if label == "" { - return fmt.Errorf("empty label") - } - if len(label) > MaxAgentLabelLen { - return fmt.Errorf("label %q exceeds %d characters", label, MaxAgentLabelLen) - } - for i, r := range label { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_': - case r == '-' && i > 0: - default: - return fmt.Errorf("label %q contains an invalid character %q", label, r) - } - } - return nil -} - -// AgentMember renders an agent identifier as a policy member or target, the -// form used in allowed_targets and role bindings. -func AgentMember(id string) (string, error) { - if err := ValidateAgentID(id); err != nil { - return "", err - } - return FactAgent + ":" + id, nil -} - -// ValidateAgentPattern checks one entry of a role's allowed_agents: an agent -// namespace the holder can act for. Unlike ValidateAgentID it allows the -// wildcard forms, because a namespace grant is a pattern. -// -// A bare "*" is accepted and means any agent, which lets every holder of the -// role name any agent in the mesh. Some meshes have a single tenant, so it -// stays expressible, but callers should warn when they see it. -func ValidateAgentPattern(pattern string) error { - p := strings.TrimPrefix(pattern, FactAgent+":") - if p == "" { - return fmt.Errorf("agent namespace cannot be empty") - } - if p == "*" { - return nil - } - switch { - case strings.HasPrefix(p, "*."): - // Validate the remaining labels, which must still be a real authority. - return validateAgentLabels(p[2:], pattern) - case strings.HasSuffix(p, ".*"): - return validateAgentLabels(p[:len(p)-2], pattern) - } - if strings.Contains(p, "*") { - return fmt.Errorf("agent namespace %q may only use a wildcard as a leading %q or trailing %q label", pattern, "*.", ".*") - } - return ValidateAgentID(p) -} - -// validateAgentLabels applies the identifier's label rules to the non-wildcard -// part of a pattern, so "*.PROD.acme" or "*..acme" is rejected at config time -// rather than silently matching nothing. -func validateAgentLabels(rest, pattern string) error { - if rest == "" { - return fmt.Errorf("agent namespace %q must keep at least one label beside the wildcard", pattern) - } - if rest != strings.ToLower(rest) { - return fmt.Errorf("agent namespace %q must be lowercase", pattern) - } - if strings.Contains(rest, "*") { - return fmt.Errorf("agent namespace %q may only use one wildcard", pattern) - } - for _, label := range strings.Split(rest, ".") { - if err := validateAgentLabel(label); err != nil { - return fmt.Errorf("agent namespace %q: %w", pattern, err) - } - } - return nil -} diff --git a/api/agent_namespace_test.go b/api/agent_namespace_test.go deleted file mode 100644 index cfa23b76..00000000 --- a/api/agent_namespace_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// 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. - -package api - -import ( - "testing" - - "github.com/biscuit-auth/biscuit-go/v2" -) - -func TestValidateAgentPattern(t *testing.T) { - tests := []struct { - name string - pattern string - wantErr bool - }{ - {name: "exact id", pattern: "reviewer-7.prod.acme.example"}, - {name: "with the agent prefix", pattern: "agent:reviewer-7.prod.acme.example"}, - {name: "suffix wildcard", pattern: "*.prod.acme.example"}, - {name: "prefix wildcard", pattern: "acme.*"}, - {name: "bare wildcard grants everything", pattern: "*"}, - - {name: "empty", pattern: "", wantErr: true}, - {name: "wildcard alone beside a dot", pattern: "*.", wantErr: true}, - {name: "wildcard in the middle", pattern: "reviewer.*.acme.example", wantErr: true}, - {name: "two wildcards", pattern: "*.acme.*", wantErr: true}, - {name: "uppercase", pattern: "*.PROD.acme.example", wantErr: true}, - {name: "empty label", pattern: "*..acme.example", wantErr: true}, - // An unqualified id has no authority, so it would collide across tenants. - {name: "exact id with no authority", pattern: "reviewer-7", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateAgentPattern(tt.pattern) - if tt.wantErr && err == nil { - t.Errorf("ValidateAgentPattern(%q) = nil, want an error", tt.pattern) - } - if !tt.wantErr && err != nil { - t.Errorf("ValidateAgentPattern(%q) = %v, want nil", tt.pattern, err) - } - }) - } -} - -// TestBuildAgentDatalogFactKeepsTheWildcardAnchored pins the property the whole -// namespace bound rests on: a suffix grant keeps its leading dot, so -// "evil-prod.acme.example" is a different namespace from "*.prod.acme.example" -// rather than a match for it. -func TestBuildAgentDatalogFactKeepsTheWildcardAnchored(t *testing.T) { - tests := []struct { - pattern string - wantName string - wantVal string - }{ - {"*.prod.acme.example", FactGrantedAgentSuffix, ".prod.acme.example"}, - {"acme.*", FactGrantedAgentPrefix, "acme."}, - {"reviewer-7.prod.acme.example", FactGrantedAgentExact, "reviewer-7.prod.acme.example"}, - } - - for _, tt := range tests { - t.Run(tt.pattern, func(t *testing.T) { - fact := BuildAgentDatalogFact(tt.pattern) - if fact.Name != tt.wantName { - t.Fatalf("BuildAgentDatalogFact(%q) name = %q, want %q", tt.pattern, fact.Name, tt.wantName) - } - got, ok := fact.IDs[0].(biscuit.String) - if !ok || string(got) != tt.wantVal { - t.Errorf("BuildAgentDatalogFact(%q) value = %v, want %q", tt.pattern, fact.IDs[0], tt.wantVal) - } - }) - } - - if got := BuildAgentDatalogFact("*").Name; got != FactGrantedAgentAll { - t.Errorf(`BuildAgentDatalogFact("*") name = %q, want %q`, got, FactGrantedAgentAll) - } -} - -// TestBuildAgentDatalogFactsMergesExactGrants keeps a role naming many agents -// from costing one world fact each, the same way service and target grants are -// merged: the authorizer rejects worlds beyond ~1000 facts. -func TestBuildAgentDatalogFactsMergesExactGrants(t *testing.T) { - facts := BuildAgentDatalogFacts([]string{ - "a.acme.example", - "b.acme.example", - "c.acme.example", - "*.prod.acme.example", - }) - - var sets, suffixes int - for _, f := range facts { - switch f.Name { - case FactGrantedAgentSet: - sets++ - set, ok := f.IDs[0].(biscuit.Set) - if !ok { - t.Fatalf("granted_agent_set term is %T, want biscuit.Set", f.IDs[0]) - } - if len(set) != 3 { - t.Errorf("granted_agent_set holds %d ids, want 3", len(set)) - } - case FactGrantedAgentSuffix: - suffixes++ - default: - t.Errorf("unexpected fact %q", f.Name) - } - } - if sets != 1 || suffixes != 1 { - t.Errorf("got %d set facts and %d suffix facts, want 1 and 1", sets, suffixes) - } -} diff --git a/api/agent_test.go b/api/agent_test.go deleted file mode 100644 index 3798eefd..00000000 --- a/api/agent_test.go +++ /dev/null @@ -1,112 +0,0 @@ -// 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. - -package api - -import ( - "strings" - "testing" - - "github.com/biscuit-auth/biscuit-go/v2" -) - -func TestValidateAgentID(t *testing.T) { - tests := []struct { - name string - id string - wantErr bool - }{ - {"fully qualified", "reviewer-7.prod.acme.example", false}, - {"two labels", "reviewer.acme", false}, - {"digits and underscores", "actor_1.ns0.acme.example", false}, - {"substrate actor host", "my-counter-1.demo.actors.resources.substrate.ate.dev", false}, - - {"empty", "", true}, - {"single label has no authority", "reviewer", true}, - {"uppercase", "Reviewer.acme.example", true}, - {"wildcard is a pattern not an identity", "*.prod.acme.example", true}, - {"trailing wildcard", "acme.*", true}, - {"carries the prefix", "agent:reviewer.acme.example", true}, - {"contains a path", "acme.example/reviewer", true}, - {"contains a space", "reviewer 7.acme.example", true}, - {"empty label", "reviewer..acme", true}, - {"leading dot", ".acme.example", true}, - {"trailing dot", "reviewer.acme.", true}, - {"label starts with a hyphen", "-reviewer.acme.example", true}, - {"label too long", strings.Repeat("a", 64) + ".acme", true}, - {"id too long", strings.Repeat("a.", 130) + "acme", true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := ValidateAgentID(tc.id) - if tc.wantErr && err == nil { - t.Fatalf("ValidateAgentID(%q) = nil, want error", tc.id) - } - if !tc.wantErr && err != nil { - t.Fatalf("ValidateAgentID(%q) returned error: %v", tc.id, err) - } - }) - } -} - -func TestAgentMember(t *testing.T) { - got, err := AgentMember("reviewer-7.prod.acme.example") - if err != nil { - t.Fatalf("AgentMember returned error: %v", err) - } - if want := "agent:reviewer-7.prod.acme.example"; got != want { - t.Fatalf("AgentMember = %q, want %q", got, want) - } - - if _, err := AgentMember("*.prod.acme.example"); err == nil { - t.Fatal("AgentMember accepted a wildcard, want error") - } -} - -// TestAgentPolicyPatternsAreLabelAnchored is the reason agent identifiers are -// dot-separated: the existing target vocabulary compiles wildcards into facts -// that keep the anchoring dot, so a lookalike authority cannot match. -func TestAgentPolicyPatternsAreLabelAnchored(t *testing.T) { - tests := []struct { - name string - pattern string - wantFact string - wantValue string - }{ - {"suffix keeps the leading dot", "agent:*.prod.acme.example", FactGrantedTargetSuffix, ".prod.acme.example"}, - {"prefix keeps the trailing dot", "agent:acme.*", FactGrantedTargetPrefix, "acme."}, - {"exact", "agent:reviewer-7.prod.acme.example", FactGrantedTargetExact, "reviewer-7.prod.acme.example"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - fact := BuildTargetDatalogFact(tc.pattern) - if fact.Name != tc.wantFact { - t.Fatalf("BuildTargetDatalogFact(%q) produced %q, want %q", tc.pattern, fact.Name, tc.wantFact) - } - if len(fact.IDs) != 2 { - t.Fatalf("BuildTargetDatalogFact(%q) produced %d terms, want 2", tc.pattern, len(fact.IDs)) - } - gotFactName, ok := fact.IDs[0].(biscuit.String) - if !ok || string(gotFactName) != FactAgent { - t.Errorf("BuildTargetDatalogFact(%q) targets %v, want %q", tc.pattern, fact.IDs[0], FactAgent) - } - gotValue, ok := fact.IDs[1].(biscuit.String) - if !ok || string(gotValue) != tc.wantValue { - t.Errorf("BuildTargetDatalogFact(%q) value = %v, want %q", tc.pattern, fact.IDs[1], tc.wantValue) - } - }) - } -} diff --git a/api/datalog.go b/api/datalog.go index b2ff09ba..50fbe054 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -74,14 +74,6 @@ const ( // Example Datalog: allow if node("12D3KooWP2G8nJCLASp1Kb4TmQS4wCpMH2vpSUz8ug8DYEJiuf1i") FactNode = "node" - // FactAgent defines the agent on whose behalf a request is made. Unlike - // FactNode it does not identify a host: it is appended to the token when an - // agent is admitted, and the same identifier is asserted again wherever that - // agent is next resumed. See api/agent.go for the identifier rules. - // Contains: biscuit.String(agentID) - // Example Datalog: allow if agent("reviewer-7.prod.acme.example") - FactAgent = "agent" - // FactClientPeerID defines the client PeerID performing the request, used for replay defense. // Contains: biscuit.String(clientPeerID) // Example Datalog: check if client_peer_id($id), connection_peer_id($id) @@ -123,7 +115,6 @@ const ( // Standard role values RoleRouter = "sam:role:router" RoleNode = "sam:role:node" - RoleSamBox = "sam:role:sambox" // FactUser defines the subject (username/userID) claim extracted from the OIDC token. // Contains: biscuit.String(username) @@ -196,42 +187,6 @@ const ( // Contains: biscuit.String(factName), biscuit.Set of biscuit.String(factValue) FactGrantedTargetSet = "granted_target_set" - // The granted_agent_* family answers a different question from the - // granted_target_* family above. A target grant says which destinations the - // holder can reach. An agent grant says which agent identities the holder - // can act for. A target grant must never satisfy an agent claim: being - // allowed to call an agent is not being allowed to impersonate it. - - // FactGrantedAgentExact allows the holder to act for one exact agent id. - // Contains: biscuit.String(agentID) - FactGrantedAgentExact = "granted_agent_exact" - - // FactGrantedAgentSet allows the holder to act for a Set of exact agent ids, - // so many exact grants cost one fact instead of one fact each. - // Contains: biscuit.Set of biscuit.String(agentID) - FactGrantedAgentSet = "granted_agent_set" - - // FactGrantedAgentPrefix allows the holder to act for any agent id starting - // with the prefix, e.g. "reviewer.*" -> "reviewer.". - // Contains: biscuit.String(prefix) - FactGrantedAgentPrefix = "granted_agent_prefix" - - // FactGrantedAgentSuffix allows the holder to act for any agent id ending - // with the suffix, e.g. "*.prod.acme.example" -> ".prod.acme.example". The - // leading dot is kept so the wildcard lands on a label boundary and - // "evil-acme.example" cannot match "*.acme.example". - // Contains: biscuit.String(suffix) - FactGrantedAgentSuffix = "granted_agent_suffix" - - // FactGrantedAgentAll allows the holder to act for any agent at all. - // Contains: biscuit.Bool(true) (marker fact) - FactGrantedAgentAll = "granted_agent_all" - - // FactAgentAuthorized is derived when an agent claim falls inside one of the - // holder's granted_agent_* namespaces. - // Contains: biscuit.Bool(true) (marker fact) - FactAgentAuthorized = "agent_authorized" - // FactConnectionPeerID defines the actual PeerID of the remote peer making the connection. // Contains: biscuit.String(connectionPeerID) // Example Datalog: check if client_peer_id($id), connection_peer_id($id) @@ -437,13 +392,6 @@ var ( // BaselineTargetCheck verifies that the target matches one of the allowed network targets. BaselineTargetCheck biscuit.Check - // BaselineAgentRules derive agent_authorized from the holder's granted_agent_* facts. - BaselineAgentRules []biscuit.Rule - - // BaselineAgentCheck verifies that the holder may speak for the agent it named. - // Only added when a request carries an agent claim; see node.SamNode.Authorize. - BaselineAgentCheck biscuit.Check - // TargetFactRules maps node and OIDC claims to target_fact datalog facts. TargetFactRules []biscuit.Rule @@ -470,16 +418,12 @@ type DatalogSources struct { Rules []string `json:"rules"` // HTTPRules derive service grants from narrowed grants (BaselineHTTPRules). HTTPRules []string `json:"http_rules"` - // AgentRules derive agent_authorized from agent grants (BaselineAgentRules). - AgentRules []string `json:"agent_rules"` // TargetFactRules map identity facts to target_fact (TargetFactRules). TargetFactRules []string `json:"target_fact_rules"` // ReplayCheck is BaselineReplayCheck. ReplayCheck string `json:"replay_check"` // TargetCheck is BaselineTargetCheck. TargetCheck string `json:"target_check"` - // AgentCheck is BaselineAgentCheck. - AgentCheck string `json:"agent_check"` // TimeCheck is ControlPlaneStaticTimeCheck. TimeCheck string `json:"time_check"` // AllowIfTrue is AllowIfTruePolicy. @@ -598,33 +542,6 @@ func init() { panic(fmt.Sprintf("failed to parse target check: %v", err)) } - // 3. Agent Namespace Rules. - // An agent claim is the calling node's word, so it is only worth what the - // control plane attested about that node. These derive agent_authorized when - // the claim falls inside a namespace the caller's own token grants. - BaselineSources.AgentRules = []string{ - fmt.Sprintf(`%s(true) <- %s($a), %s($a)`, FactAgentAuthorized, FactAgent, FactGrantedAgentExact), - fmt.Sprintf(`%s(true) <- %s($a), %s($set), $set.contains($a)`, FactAgentAuthorized, FactAgent, FactGrantedAgentSet), - fmt.Sprintf(`%s(true) <- %s($a), %s($prefix), $a.starts_with($prefix)`, FactAgentAuthorized, FactAgent, FactGrantedAgentPrefix), - fmt.Sprintf(`%s(true) <- %s($a), %s($suffix), $a.ends_with($suffix)`, FactAgentAuthorized, FactAgent, FactGrantedAgentSuffix), - fmt.Sprintf(`%s(true) <- %s($a), %s(true)`, FactAgentAuthorized, FactAgent, FactGrantedAgentAll), - } - for i, rStr := range BaselineSources.AgentRules { - r, err := parser.FromStringRule(rStr) - if err != nil { - panic(fmt.Sprintf("failed to parse baseline agent rule %d: %v", i, err)) - } - BaselineAgentRules = append(BaselineAgentRules, r) - } - - // A token carrying no granted_agent_* fact derives nothing, so this fails - // closed: naming an agent you were never granted denies the request. - BaselineSources.AgentCheck = fmt.Sprintf(`check if %s(true)`, FactAgentAuthorized) - BaselineAgentCheck, err = parser.FromStringCheck(BaselineSources.AgentCheck) - if err != nil { - panic(fmt.Sprintf("failed to parse agent check: %v", err)) - } - // OIDC Claims to Target Facts: Maps dynamically generated OIDC facts (like `user("alice")`) // into standard `target_fact("user", "alice")` facts for unified evaluation against network target policies. // Node PeerID Target Fact: Ensures the target node's PeerID is also evaluated as a standard target_fact. @@ -828,65 +745,6 @@ func isExactTarget(targetStr string) (tFact, tVal string, exact bool) { return tFact, tVal, BuildTargetDatalogFact(targetStr).Name == FactGrantedTargetExact } -// BuildAgentDatalogFact translates one agent namespace pattern into a Datalog fact. -// Patterns are the agent id shapes of §8.8: "*", "*.suffix", "prefix.*" or an exact id. -func BuildAgentDatalogFact(pattern string) biscuit.Fact { - pattern = strings.TrimPrefix(pattern, FactAgent+":") - switch { - case pattern == "*": - return MarkerFact(FactGrantedAgentAll) - case strings.HasPrefix(pattern, "*."): - return biscuit.Fact{Predicate: biscuit.Predicate{ - Name: FactGrantedAgentSuffix, - IDs: []biscuit.Term{biscuit.String(pattern[1:])}, - }} - case strings.HasSuffix(pattern, ".*"): - return biscuit.Fact{Predicate: biscuit.Predicate{ - Name: FactGrantedAgentPrefix, - IDs: []biscuit.Term{biscuit.String(pattern[:len(pattern)-1])}, - }} - } - return biscuit.Fact{Predicate: biscuit.Predicate{ - Name: FactGrantedAgentExact, - IDs: []biscuit.Term{biscuit.String(pattern)}, - }} -} - -// BuildAgentDatalogFacts translates a list of agent namespace patterns into a -// minimal set of facts, merging exact ids into one granted_agent_set so a role -// naming many agents still costs one fact. -func BuildAgentDatalogFacts(patterns []string) []biscuit.Fact { - facts := make([]biscuit.Fact, 0, len(patterns)) - exact := make(map[string]bool) - for _, p := range patterns { - trimmed := strings.TrimPrefix(p, FactAgent+":") - if trimmed == "" { - continue - } - if trimmed == "*" || strings.HasPrefix(trimmed, "*.") || strings.HasSuffix(trimmed, ".*") { - facts = append(facts, BuildAgentDatalogFact(trimmed)) - continue - } - exact[trimmed] = true - } - if len(exact) > 0 { - ids := make([]string, 0, len(exact)) - for id := range exact { - ids = append(ids, id) - } - sort.Strings(ids) - bset := make(biscuit.Set, 0, len(ids)) - for _, id := range ids { - bset = append(bset, biscuit.String(id)) - } - facts = append(facts, biscuit.Fact{Predicate: biscuit.Predicate{ - Name: FactGrantedAgentSet, - IDs: []biscuit.Term{bset}, - }}) - } - return facts -} - // BuildServiceDatalogFacts translates a list of service patterns into a minimal set of Datalog facts. // Exact-match entries are grouped by service type into a single granted_service_set fact each, so // token/world fact counts stay flat regardless of how many exact services a role grants. Wildcard, diff --git a/api/egress_test.go b/api/egress_test.go index 52dfa7da..e8d47c01 100644 --- a/api/egress_test.go +++ b/api/egress_test.go @@ -74,19 +74,6 @@ func TestValidateEgressServicePattern(t *testing.T) { } } -func TestEgressHasNoMeshHost(t *testing.T) { - if _, err := ParseMeshHost("api.github.com.egress.sam.alt"); err == nil || !strings.Contains(err.Error(), "egress") { - t.Errorf("ParseMeshHost accepted an egress projection: %v", err) - } - if _, err := MeshHost(ServiceType_SERVICE_TYPE_EGRESS, "api.github.com"); err == nil { - t.Error("MeshHost rendered an egress destination") - } - // The other types are unaffected. - if uri, err := ParseMeshHost("tools.mcp.sam.alt"); err != nil || uri != "mcp://tools" { - t.Errorf("ParseMeshHost(tools.mcp.sam.alt) = %q, %v", uri, err) - } -} - func TestValidateEgressDestination(t *testing.T) { roles := map[string]bool{"pep": true} tests := []struct { diff --git a/api/names.go b/api/names.go index 720b627f..9094f622 100644 --- a/api/names.go +++ b/api/names.go @@ -14,159 +14,10 @@ package api -import ( - "fmt" - "strings" -) +import "strings" -// ============================================================================ -// Sandbox Mesh Names -// ============================================================================ -// -// Agents run inside sandboxes (Firecracker microVMs, network=none containers) -// and reach the mesh by connecting to a *name*, exactly as they would reach the -// public internet. This file defines the one projection of the mesh service -// namespace into DNS-shaped names, so that a hostname seen on the sandbox -// boundary and a service URI seen by the policy engine are the same identity -// written two ways: -// -// inference://openrouter <-> openrouter.inference.sam.alt -// mcp://code-reviewer <-> code-reviewer.mcp.sam.alt -// -// The URI form (see MCPServicePrefix / InferenceServicePrefix in network.go) is -// canonical: it is what the control plane authorizes in allowed_services and -// what lands in the Biscuit service() fact. The hostname form exists only so -// unmodified agents can use an unmodified HTTP client. Never introduce a -// routing decision that can be expressed in one form but not the other. - -const ( - // MeshZone is the DNS suffix under which mesh services are addressed from - // inside a sandbox. - // - // ".alt" is the pseudo-top-level domain reserved by RFC 9476 for namespaces - // that are explicitly NOT resolved through the DNS. That is precisely this - // case: these names are resolved by the mesh (service discovery over - // libp2p), never by a resolver. Using it guarantees the zone can never - // collide with a delegated gTLD, and guarantees a name that leaks out of a - // sandbox fails closed instead of resolving to somebody else's host. - MeshZone = "sam.alt" - - // MeshEntrypointHost is the reserved name an agent uses to reach the mesh - // services its gateway offers it: inference and tools, with the provider - // chosen by policy. - // - // It deliberately does not name the node. A sam-node's sidecar API is a - // local, operator-facing surface — it can register services, drive the raw - // egress proxy and read node internals — and an agent has no business - // reaching any of it. The gateway consumes the node; the agent consumes the - // mesh through the gateway, and the two must not be the same address. - MeshEntrypointHost = "mesh." + MeshZone -) - -// meshZoneSuffix is the dotted form used for suffix matching. -const meshZoneSuffix = "." + MeshZone - -// NormalizeMeshHost canonicalizes a hostname taken off the sandbox boundary: it -// drops a trailing root dot and lowercases the name. DNS names are -// case-insensitive, so a mesh name only ever addresses a lowercase service -// name; services registered with uppercase characters are reachable by URI but -// not by hostname. +// NormalizeMeshHost canonicalizes a hostname: it drops a trailing root dot and +// lowercases the name. func NormalizeMeshHost(host string) string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) } - -// IsMeshHost reports whether host falls inside the mesh zone. It does not -// validate the name beyond the suffix: use ParseMeshHost for that. -func IsMeshHost(host string) bool { - h := NormalizeMeshHost(host) - return h == MeshZone || strings.HasSuffix(h, meshZoneSuffix) -} - -// IsMeshEntrypointHost reports whether host addresses the gateway's own -// agent-facing surface. -func IsMeshEntrypointHost(host string) bool { - return NormalizeMeshHost(host) == MeshEntrypointHost -} - -// ParseMeshHost translates a mesh hostname into its canonical service URI. -// -// openrouter.inference.sam.alt -> inference://openrouter -// code-reviewer.mcp.sam.alt -> mcp://code-reviewer -// -// The service type is the label immediately left of the zone; everything to its -// left is the service name, which may itself contain dots (service names are -// validated as DNS names, not as single labels). MeshEntrypointHost is not a -// service and is rejected here; callers must test it with IsMeshEntrypointHost -// first. -// -// Names are not resolved to a provider: which peer serves the returned URI is a -// discovery decision, and deliberately not encoded in the name. If pinning to -// one provider is ever needed, the natural extension is a longer form carrying -// the peer — mirroring the internal libp2p://// URL — but it -// requires settling on a DNS-safe peer encoding first, because a base58 peer ID -// is case-sensitive and DNS labels are not (IPFS solves the same problem in -// subdomain gateways by using lowercase base36 CIDs). -func ParseMeshHost(host string) (serviceURI string, err error) { - h := NormalizeMeshHost(host) - if h == "" { - return "", fmt.Errorf("empty mesh host") - } - // A hostname carries neither a port nor a path: stripping those is the - // caller's job, and anything else here means a malformed request that must - // fail closed rather than be coerced into a service URI. - if strings.ContainsAny(h, ":/") { - return "", fmt.Errorf("mesh host %q must not contain a port or a path", host) - } - if h == MeshEntrypointHost { - return "", fmt.Errorf("%q is the gateway entrypoint, not a mesh service", host) - } - rest, found := strings.CutSuffix(h, meshZoneSuffix) - if !found || rest == "" { - return "", fmt.Errorf("host %q is not in the mesh zone %q", host, MeshZone) - } - - dot := strings.LastIndex(rest, ".") - if dot <= 0 || dot == len(rest)-1 { - return "", fmt.Errorf("mesh host %q must be ..%s", host, MeshZone) - } - name, typeStr := rest[:dot], rest[dot+1:] - - serviceType, err := ParseServiceType(typeStr) - if err != nil { - return "", fmt.Errorf("mesh host %q: %w", host, err) - } - // An egress destination is addressed by its own name; projecting it into - // the zone would give one destination two names on the boundary. - if serviceType == ServiceType_SERVICE_TYPE_EGRESS { - return "", fmt.Errorf("mesh host %q: egress destinations have no %s name; connect to %q itself", host, MeshZone, name) - } - - uri := typeStr + "://" + name - if err := ValidateServiceFormat(uri); err != nil { - return "", fmt.Errorf("mesh host %q: %w", host, err) - } - return uri, nil -} - -// MeshHost is the inverse of ParseMeshHost: it renders the hostname a sandboxed -// agent should connect to in order to reach the given service. -func MeshHost(t ServiceType, serviceName string) (string, error) { - if t == ServiceType_SERVICE_TYPE_EGRESS { - return "", fmt.Errorf("egress destination %q has no %s name; it is reached by its own name", serviceName, MeshZone) - } - typeStr, err := ServiceTypeToString(t) - if err != nil { - return "", err - } - if serviceName == "" { - return "", fmt.Errorf("service name cannot be empty") - } - if serviceName != NormalizeMeshHost(serviceName) { - return "", fmt.Errorf("service name %q is not addressable as a mesh host: it must be lowercase", serviceName) - } - host := serviceName + "." + typeStr + meshZoneSuffix - if _, err := ParseMeshHost(host); err != nil { - return "", err - } - return host, nil -} diff --git a/api/names_test.go b/api/names_test.go index b1912894..ed3ddf5a 100644 --- a/api/names_test.go +++ b/api/names_test.go @@ -16,126 +16,19 @@ package api import "testing" -func TestParseMeshHost(t *testing.T) { +func TestNormalizeMeshHost(t *testing.T) { tests := []struct { - name string - host string - want string - wantErr bool + in string + want string }{ - {"inference service", "openrouter.inference.sam.alt", "inference://openrouter", false}, - {"mcp service", "code-reviewer.mcp.sam.alt", "mcp://code-reviewer", false}, - {"trailing root dot", "calculator.mcp.sam.alt.", "mcp://calculator", false}, - {"uppercase is folded", "Calculator.MCP.Sam.Alt", "mcp://calculator", false}, - {"dotted service name", "my-service.local.mcp.sam.alt", "mcp://my-service.local", false}, - {"underscore service name", "my_service.mcp.sam.alt", "mcp://my_service", false}, - - {"local node is not a service", "mesh.sam.alt", "", true}, - {"unknown service type", "thing.storage.sam.alt", "", true}, - {"missing service type", "calculator.sam.alt", "", true}, - {"zone only", "sam.alt", "", true}, - {"outside the zone", "api.github.com", "", true}, - {"zone as a substring", "evil-sam.alt", "", true}, - {"empty service name", ".mcp.sam.alt", "", true}, - {"empty", "", "", true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := ParseMeshHost(tc.host) - if tc.wantErr { - if err == nil { - t.Fatalf("ParseMeshHost(%q) = %q, want error", tc.host, got) - } - return - } - if err != nil { - t.Fatalf("ParseMeshHost(%q) returned error: %v", tc.host, err) - } - if got != tc.want { - t.Errorf("ParseMeshHost(%q) = %q, want %q", tc.host, got, tc.want) - } - }) - } -} - -func TestMeshHostRoundTrip(t *testing.T) { - tests := []struct { - svcType ServiceType - name string - want string - wantURI string - }{ - {ServiceType_SERVICE_TYPE_MCP, "calculator", "calculator.mcp.sam.alt", "mcp://calculator"}, - {ServiceType_SERVICE_TYPE_INFERENCE, "openrouter", "openrouter.inference.sam.alt", "inference://openrouter"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - host, err := MeshHost(tc.svcType, tc.name) - if err != nil { - t.Fatalf("MeshHost(%v, %q) returned error: %v", tc.svcType, tc.name, err) - } - if host != tc.want { - t.Fatalf("MeshHost(%v, %q) = %q, want %q", tc.svcType, tc.name, host, tc.want) - } - uri, err := ParseMeshHost(host) - if err != nil { - t.Fatalf("ParseMeshHost(%q) returned error: %v", host, err) - } - if uri != tc.wantURI { - t.Errorf("round trip of %q = %q, want %q", tc.name, uri, tc.wantURI) - } - }) - } -} - -func TestMeshHostRejects(t *testing.T) { - tests := []struct { - name string - svcType ServiceType - svcName string - }{ - {"unspecified type", ServiceType_SERVICE_TYPE_UNSPECIFIED, "calculator"}, - {"empty name", ServiceType_SERVICE_TYPE_MCP, ""}, - {"uppercase name is not addressable", ServiceType_SERVICE_TYPE_MCP, "Calculator"}, - {"name with a path", ServiceType_SERVICE_TYPE_MCP, "calculator/add"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if host, err := MeshHost(tc.svcType, tc.svcName); err == nil { - t.Fatalf("MeshHost(%v, %q) = %q, want error", tc.svcType, tc.svcName, host) - } - }) + {"api.github.com", "api.github.com"}, + {"API.GitHub.COM.", "api.github.com"}, + {" example.com. ", "example.com"}, + {"", ""}, } -} - -func TestIsMeshHostAndIsMeshEntrypointHost(t *testing.T) { - tests := []struct { - host string - isMesh bool - isEntry bool - }{ - {"mesh.sam.alt", true, true}, - {"MESH.SAM.ALT.", true, true}, - {"calculator.mcp.sam.alt", true, false}, - {"sam.alt", true, false}, - {"node.sam.alt", true, false}, - {"api.github.com", false, false}, - {"evil-sam.alt", false, false}, - {"mesh.sam.alt.evil.com", false, false}, - {"", false, false}, - } - for _, tc := range tests { - t.Run(tc.host, func(t *testing.T) { - if got := IsMeshHost(tc.host); got != tc.isMesh { - t.Errorf("IsMeshHost(%q) = %v, want %v", tc.host, got, tc.isMesh) - } - if got := IsMeshEntrypointHost(tc.host); got != tc.isEntry { - t.Errorf("IsMeshEntrypointHost(%q) = %v, want %v", tc.host, got, tc.isEntry) - } - }) + if got := NormalizeMeshHost(tc.in); got != tc.want { + t.Errorf("NormalizeMeshHost(%q) = %q, want %q", tc.in, got, tc.want) + } } } diff --git a/api/network.go b/api/network.go index 1e2609e1..2dea72f7 100644 --- a/api/network.go +++ b/api/network.go @@ -147,17 +147,6 @@ const ( // same way for locally served requests. HeaderPeerID = "X-Peer-Id" - // HeaderSamAgent names the agent a request is made on behalf of, as a - // canonical agent identifier (see api/agent.go). It is set by the sandbox - // gateway on the node's local API socket, and honoured by the node only - // there: arriving on that socket is proof the caller is the gateway, which - // is the only party that knows which agent a flow belongs to. - // - // A sandboxed agent can never set it. The gateway overwrites the header on - // every request it forwards, so a value an agent supplies is replaced by - // the identity the platform bound to its channel, never merged with it. - HeaderSamAgent = "X-Sam-Agent" - // HeaderSamAuthentication is the custom HTTP header used to authenticate a local // process to this node's sidecar API (the shared secret configured via // "--api-token-path" or the SAM_API_TOKEN environment variable). Using a diff --git a/api/policy_rules.go b/api/policy_rules.go index 22384ffd..7273e36a 100644 --- a/api/policy_rules.go +++ b/api/policy_rules.go @@ -115,13 +115,6 @@ func BuildPolicyRules(roles []*PolicyRole, bindings []*PolicyBinding) (rules []P add(fact.Predicate, fromRole) } - for _, fact := range BuildAgentDatalogFacts(role.AllowedAgents) { - if fact.Name == FactGrantedAgentAll { - warnings = append(warnings, fmt.Sprintf("Role %s may speak for any agent; any peer holding it can name any agent identity in the mesh", roleName)) - } - add(fact.Predicate, fromRole) - } - // Custom entries keep their source text: it may carry expressions, // which biscuit-go cannot print back. for _, dl := range role.CustomDatalog { diff --git a/api/policy_rules_test.go b/api/policy_rules_test.go index cd7b82ab..b0d2c427 100644 --- a/api/policy_rules_test.go +++ b/api/policy_rules_test.go @@ -40,7 +40,6 @@ func TestBuildPolicyRules(t *testing.T) { Name: "test-role", AllowedTargets: []string{"*", "node:peer-abc", "custom-fact:custom-val", "legacy-peer"}, AllowedServices: []string{"*:*", "mcp:*", "mcp:*.suffix", "mcp:prefix.*", "mcp:exact"}, - AllowedAgents: []string{"*"}, CustomDatalog: []string{ "custom_rule($x) <- fact($x), $x > 3;", "custom_fact(\"hello\")", @@ -70,7 +69,6 @@ func TestBuildPolicyRules(t *testing.T) { "target_restricted(true) <- role(\"test-role\")": false, "granted_target_set(\"node\", [\"legacy-peer\", \"peer-abc\"]) <- role(\"test-role\")": false, "granted_target_set(\"custom-fact\", [\"custom-val\"]) <- role(\"test-role\")": false, - "granted_agent_all(true) <- role(\"test-role\")": false, "custom_rule($x) <- fact($x), $x > 3": false, "custom_fact(\"hello\") <- true": false, } @@ -93,8 +91,8 @@ func TestBuildPolicyRules(t *testing.T) { } } - if len(warnings) != 3 { - t.Fatalf("warnings = %q, want one for granted_agent_all, one for the unparseable entry and one for the bad peer ID", warnings) + if len(warnings) != 2 { + t.Fatalf("warnings = %q, want one for the unparseable entry and one for the bad peer ID", warnings) } if !strings.Contains(strings.Join(warnings, "\n"), `"node:not-a-peer-id"`) { t.Errorf("warnings = %q, want one naming the member that is not a peer ID", warnings) diff --git a/api/sam.pb.go b/api/sam.pb.go index 4d830601..4f5a5565 100644 --- a/api/sam.pb.go +++ b/api/sam.pb.go @@ -98,8 +98,7 @@ const ( // A destination outside the mesh, reached through a node that enforces // policy on it. The service name is the destination hostname, so a grant // reads egress://api.github.com and the request fact - // service("egress", "api.github.com"). Egress names have no .sam.alt form: - // a sandboxed agent connects to the destination name itself. + // service("egress", "api.github.com"). ServiceType_SERVICE_TYPE_EGRESS ServiceType = 4 ) @@ -201,11 +200,6 @@ type AuthFrame struct { state protoimpl.MessageState `protogen:"open.v1"` Biscuit []byte `protobuf:"bytes,1,opt,name=biscuit,proto3" json:"biscuit,omitempty"` TargetService string `protobuf:"bytes,2,opt,name=target_service,json=targetService,proto3" json:"target_service,omitempty"` // Optional: specific service requested - // The agent this request is made for, as a canonical agent identifier (see - // api/agent.go). It is the calling node's claim, carried beside the token - // because Biscuit hides an appended block's facts from the authorizer; the - // HTTP datapath carries the same claim in HeaderSamAgent. - Agent string `protobuf:"bytes,3,opt,name=agent,proto3" json:"agent,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -254,13 +248,6 @@ func (x *AuthFrame) GetTargetService() string { return "" } -func (x *AuthFrame) GetAgent() string { - if x != nil { - return x.Agent - } - return "" -} - type AuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` @@ -1402,17 +1389,12 @@ type PolicyRole struct { AllowedTargets []string `protobuf:"bytes,2,rep,name=allowed_targets,json=allowedTargets,proto3" json:"allowed_targets,omitempty"` AllowedServices []string `protobuf:"bytes,3,rep,name=allowed_services,json=allowedServices,proto3" json:"allowed_services,omitempty"` CustomDatalog []string `protobuf:"bytes,4,rep,name=custom_datalog,json=customDatalog,proto3" json:"custom_datalog,omitempty"` - // Agent namespaces the holder may speak for, e.g. "*.prod.acme.example". - // An agent claim is the calling node's word, so it is only worth what the - // control plane attested about that node. Distinct from allowed_targets: - // being allowed to call an agent is not being allowed to impersonate it. - AllowedAgents []string `protobuf:"bytes,5,rep,name=allowed_agents,json=allowedAgents,proto3" json:"allowed_agents,omitempty"` // Labels a node with this role may declare at enrollment, as "*", "key=*" // or "key=value". A node declares its own labels, so this is what turns a // declaration into something the control plane is willing to sign. - AllowedLabels []string `protobuf:"bytes,6,rep,name=allowed_labels,json=allowedLabels,proto3" json:"allowed_labels,omitempty"` + AllowedLabels []string `protobuf:"bytes,5,rep,name=allowed_labels,json=allowedLabels,proto3" json:"allowed_labels,omitempty"` // HTTP narrowing of allowed_services entries; see HTTPGrant. - Http []*HTTPGrant `protobuf:"bytes,7,rep,name=http,proto3" json:"http,omitempty"` + Http []*HTTPGrant `protobuf:"bytes,6,rep,name=http,proto3" json:"http,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1475,13 +1457,6 @@ func (x *PolicyRole) GetCustomDatalog() []string { return nil } -func (x *PolicyRole) GetAllowedAgents() []string { - if x != nil { - return x.AllowedAgents - } - return nil -} - func (x *PolicyRole) GetAllowedLabels() []string { if x != nil { return x.AllowedLabels @@ -1809,7 +1784,7 @@ func (*PolicyConfigGetRequest) Descriptor() ([]byte, []int) { type PolicyConfigGetResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // One rule per entry, rendered by the control plane with api.BuildPolicyRules. - DatalogRules []string `protobuf:"bytes,3,rep,name=datalog_rules,json=datalogRules,proto3" json:"datalog_rules,omitempty"` + DatalogRules []string `protobuf:"bytes,1,rep,name=datalog_rules,json=datalogRules,proto3" json:"datalog_rules,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2330,32 +2305,32 @@ func (x *TokenRevokeResponse) GetError() string { return "" } -// AgentSecret configures credential injection for one destination. It carries -// a path, never a value: secret material must not travel through this API. -type AgentSecret struct { - state protoimpl.MessageState `protogen:"open.v1"` - Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` - Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` // bearer | basicauth | customheader - HeaderName string `protobuf:"bytes,3,opt,name=header_name,json=headerName,proto3" json:"header_name,omitempty"` // customheader only - ValuePath string `protobuf:"bytes,4,opt,name=value_path,json=valuePath,proto3" json:"value_path,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type IdentityEvidenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` + BiscuitExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=biscuit_expire_time,json=biscuitExpireTime,proto3" json:"biscuit_expire_time,omitempty"` + ControlPlaneUrl string `protobuf:"bytes,4,opt,name=control_plane_url,json=controlPlaneUrl,proto3" json:"control_plane_url,omitempty"` + TrustedControlPlaneKeys [][]byte `protobuf:"bytes,5,rep,name=trusted_control_plane_keys,json=trustedControlPlaneKeys,proto3" json:"trusted_control_plane_keys,omitempty"` // Ed25519 SPKI DER + CheckTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=check_time,json=checkTime,proto3" json:"check_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *AgentSecret) Reset() { - *x = AgentSecret{} +func (x *IdentityEvidenceResponse) Reset() { + *x = IdentityEvidenceResponse{} mi := &file_api_sam_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AgentSecret) String() string { +func (x *IdentityEvidenceResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AgentSecret) ProtoMessage() {} +func (*IdentityEvidenceResponse) ProtoMessage() {} -func (x *AgentSecret) ProtoReflect() protoreflect.Message { +func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { mi := &file_api_sam_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2367,63 +2342,81 @@ func (x *AgentSecret) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AgentSecret.ProtoReflect.Descriptor instead. -func (*AgentSecret) Descriptor() ([]byte, []int) { +// Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. +func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { return file_api_sam_proto_rawDescGZIP(), []int{31} } -func (x *AgentSecret) GetHost() string { +func (x *IdentityEvidenceResponse) GetPeerId() string { if x != nil { - return x.Host + return x.PeerId } return "" } -func (x *AgentSecret) GetKind() string { +func (x *IdentityEvidenceResponse) GetBiscuit() []byte { if x != nil { - return x.Kind + return x.Biscuit } - return "" + return nil } -func (x *AgentSecret) GetHeaderName() string { +func (x *IdentityEvidenceResponse) GetBiscuitExpireTime() *timestamppb.Timestamp { if x != nil { - return x.HeaderName + return x.BiscuitExpireTime } - return "" + return nil } -func (x *AgentSecret) GetValuePath() string { +func (x *IdentityEvidenceResponse) GetControlPlaneUrl() string { if x != nil { - return x.ValuePath + return x.ControlPlaneUrl } return "" } -// AgentEgress is deny-by-default. Patterns are matched against the destination -// name taken from the sandbox boundary, never against a resolved address. -type AgentEgress struct { +func (x *IdentityEvidenceResponse) GetTrustedControlPlaneKeys() [][]byte { + if x != nil { + return x.TrustedControlPlaneKeys + } + return nil +} + +func (x *IdentityEvidenceResponse) GetCheckTime() *timestamppb.Timestamp { + if x != nil { + return x.CheckTime + } + return nil +} + +type PeerEvidenceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - Allow []string `protobuf:"bytes,1,rep,name=allow,proto3" json:"allow,omitempty"` - Secrets []*AgentSecret `protobuf:"bytes,2,rep,name=secrets,proto3" json:"secrets,omitempty"` + PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` + Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` + VerifyingKey []byte `protobuf:"bytes,3,opt,name=verifying_key,json=verifyingKey,proto3" json:"verifying_key,omitempty"` // Ed25519 SPKI DER, member of the trusted set + Roles []string `protobuf:"bytes,4,rep,name=roles,proto3" json:"roles,omitempty"` + Labels map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + RevocationIds []string `protobuf:"bytes,7,rep,name=revocation_ids,json=revocationIds,proto3" json:"revocation_ids,omitempty"` // hex + CheckTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=check_time,json=checkTime,proto3" json:"check_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *AgentEgress) Reset() { - *x = AgentEgress{} +func (x *PeerEvidenceResponse) Reset() { + *x = PeerEvidenceResponse{} mi := &file_api_sam_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AgentEgress) String() string { +func (x *PeerEvidenceResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AgentEgress) ProtoMessage() {} +func (*PeerEvidenceResponse) ProtoMessage() {} -func (x *AgentEgress) ProtoReflect() protoreflect.Message { +func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { mi := &file_api_sam_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2435,136 +2428,106 @@ func (x *AgentEgress) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AgentEgress.ProtoReflect.Descriptor instead. -func (*AgentEgress) Descriptor() ([]byte, []int) { +// Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. +func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { return file_api_sam_proto_rawDescGZIP(), []int{32} } -func (x *AgentEgress) GetAllow() []string { +func (x *PeerEvidenceResponse) GetPeerId() string { if x != nil { - return x.Allow + return x.PeerId } - return nil + return "" } -func (x *AgentEgress) GetSecrets() []*AgentSecret { +func (x *PeerEvidenceResponse) GetBiscuit() []byte { if x != nil { - return x.Secrets + return x.Biscuit } return nil } -// AgentIngress declares that the agent serves a mesh service. The name is the -// service half of the mesh host the rest of the mesh dials (see api/names.go); -// port is where the agent listens inside its sandbox. -type AgentIngress struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type ServiceType `protobuf:"varint,1,opt,name=type,proto3,enum=sam.v1.ServiceType" json:"type,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Port uint32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"` - Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentIngress) Reset() { - *x = AgentIngress{} - mi := &file_api_sam_proto_msgTypes[33] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentIngress) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentIngress) ProtoMessage() {} - -func (x *AgentIngress) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[33] +func (x *PeerEvidenceResponse) GetVerifyingKey() []byte { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.VerifyingKey } - return mi.MessageOf(x) + return nil } -// Deprecated: Use AgentIngress.ProtoReflect.Descriptor instead. -func (*AgentIngress) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{33} +func (x *PeerEvidenceResponse) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil } -func (x *AgentIngress) GetType() ServiceType { +func (x *PeerEvidenceResponse) GetLabels() map[string]string { if x != nil { - return x.Type + return x.Labels } - return ServiceType_SERVICE_TYPE_UNSPECIFIED + return nil } -func (x *AgentIngress) GetName() string { +func (x *PeerEvidenceResponse) GetExpireTime() *timestamppb.Timestamp { if x != nil { - return x.Name + return x.ExpireTime } - return "" + return nil } -func (x *AgentIngress) GetPort() uint32 { +func (x *PeerEvidenceResponse) GetRevocationIds() []string { if x != nil { - return x.Port + return x.RevocationIds } - return 0 + return nil } -func (x *AgentIngress) GetDescription() string { +func (x *PeerEvidenceResponse) GetCheckTime() *timestamppb.Timestamp { if x != nil { - return x.Description + return x.CheckTime } - return "" + return nil } -// AgentBundle is everything the platform declares about one agent. Its -// canonical form is a YAML file in the agent's own state directory, so that a -// suspend/resume onto another host carries it with no extra machinery; this -// message is the transport mirror of that file. -type AgentBundle struct { - state protoimpl.MessageState `protogen:"open.v1"` - Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` - // Canonical mesh identifier, without the "agent:" prefix. Dot-separated and - // DNS-shaped; see api/agent.go for the rules and why they exist. - AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - // The platform's own identifier, verbatim, kept for audit because the - // translation into agent_id is not always reversible. - ExternalId string `protobuf:"bytes,3,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` - // Path to the workload credential the platform already issues: a projected - // Kubernetes service-account token, a pod certificate, or an SVID. It is - // verified at admission against the platform's issuer and then translated - // into agent facts, the same way OIDC claims are translated at node - // enrollment. The scheduler needs no mesh credential of its own. - CredentialPath string `protobuf:"bytes,4,opt,name=credential_path,json=credentialPath,proto3" json:"credential_path,omitempty"` - Egress *AgentEgress `protobuf:"bytes,5,opt,name=egress,proto3" json:"egress,omitempty"` - Ingress []*AgentIngress `protobuf:"bytes,6,rep,name=ingress,proto3" json:"ingress,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type MemberCredential struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Base URL of the control plane that minted the biscuit. + ControlPlaneUrl string `protobuf:"bytes,1,opt,name=control_plane_url,json=controlPlaneUrl,proto3" json:"control_plane_url,omitempty"` + // The member's biscuit. + Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` + // When the biscuit expires. + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` + // Control plane signing keys trusted now; a rotation keeps several valid. + TrustedKeys []*TrustedSigningKey `protobuf:"bytes,4,rep,name=trusted_keys,json=trustedKeys,proto3" json:"trusted_keys,omitempty"` + // The keys trusted when the biscuit was issued. A key trusted now that is + // absent here means a rotation happened since: the biscuit is signed by a + // retiring key and must be refreshed before that key leaves its grace + // period. + IssuedUnderKeys [][]byte `protobuf:"bytes,5,rep,name=issued_under_keys,json=issuedUnderKeys,proto3" json:"issued_under_keys,omitempty"` + // Router multiaddrs, `/p2p/` suffixed. + RouterAddresses []string `protobuf:"bytes,6,rep,name=router_addresses,json=routerAddresses,proto3" json:"router_addresses,omitempty"` + // The session that renews an identity enrolled through the mesh's + // identity provider. Unset for a member enrolled with a bootstrap token. + OidcSession *OIDCSession `protobuf:"bytes,7,opt,name=oidc_session,json=oidcSession,proto3" json:"oidc_session,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *AgentBundle) Reset() { - *x = AgentBundle{} - mi := &file_api_sam_proto_msgTypes[34] +func (x *MemberCredential) Reset() { + *x = MemberCredential{} + mi := &file_api_sam_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AgentBundle) String() string { +func (x *MemberCredential) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AgentBundle) ProtoMessage() {} +func (*MemberCredential) ProtoMessage() {} -func (x *AgentBundle) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[34] +func (x *MemberCredential) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2575,77 +2538,86 @@ func (x *AgentBundle) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AgentBundle.ProtoReflect.Descriptor instead. -func (*AgentBundle) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{34} +// Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. +func (*MemberCredential) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{33} } -func (x *AgentBundle) GetVersion() string { +func (x *MemberCredential) GetControlPlaneUrl() string { if x != nil { - return x.Version + return x.ControlPlaneUrl } return "" } -func (x *AgentBundle) GetAgentId() string { +func (x *MemberCredential) GetBiscuit() []byte { if x != nil { - return x.AgentId + return x.Biscuit } - return "" + return nil } -func (x *AgentBundle) GetExternalId() string { +func (x *MemberCredential) GetExpireTime() *timestamppb.Timestamp { if x != nil { - return x.ExternalId + return x.ExpireTime } - return "" + return nil } -func (x *AgentBundle) GetCredentialPath() string { +func (x *MemberCredential) GetTrustedKeys() []*TrustedSigningKey { if x != nil { - return x.CredentialPath + return x.TrustedKeys } - return "" + return nil } -func (x *AgentBundle) GetEgress() *AgentEgress { +func (x *MemberCredential) GetIssuedUnderKeys() [][]byte { if x != nil { - return x.Egress + return x.IssuedUnderKeys } return nil } -func (x *AgentBundle) GetIngress() []*AgentIngress { +func (x *MemberCredential) GetRouterAddresses() []string { if x != nil { - return x.Ingress + return x.RouterAddresses } return nil } -// AgentAttachRequest admits an agent. It is idempotent on agent_id: resuming -// after a crash or a migration is another Attach, not a distinct operation. -type AgentAttachRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Bundle *AgentBundle `protobuf:"bytes,1,opt,name=bundle,proto3" json:"bundle,omitempty"` +func (x *MemberCredential) GetOidcSession() *OIDCSession { + if x != nil { + return x.OidcSession + } + return nil +} + +type TrustedSigningKey struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Raw ed25519 public key. + PublicKey []byte `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + // When the member first learned the key. A key rotated out is dropped a + // grace period after this; unset means unknown and is read as now. + ReceiveTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=receive_time,json=receiveTime,proto3" json:"receive_time,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *AgentAttachRequest) Reset() { - *x = AgentAttachRequest{} - mi := &file_api_sam_proto_msgTypes[35] +func (x *TrustedSigningKey) Reset() { + *x = TrustedSigningKey{} + mi := &file_api_sam_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AgentAttachRequest) String() string { +func (x *TrustedSigningKey) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AgentAttachRequest) ProtoMessage() {} +func (*TrustedSigningKey) ProtoMessage() {} -func (x *AgentAttachRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[35] +func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2656,45 +2628,50 @@ func (x *AgentAttachRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AgentAttachRequest.ProtoReflect.Descriptor instead. -func (*AgentAttachRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{35} +// Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. +func (*TrustedSigningKey) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{34} +} + +func (x *TrustedSigningKey) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil } -func (x *AgentAttachRequest) GetBundle() *AgentBundle { +func (x *TrustedSigningKey) GetReceiveTime() *timestamppb.Timestamp { if x != nil { - return x.Bundle + return x.ReceiveTime } return nil } -type AgentAttachResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Sandbox boundary endpoints to wire into the sandbox: named HTTP tunnels - // (CONNECT, connect-udp) for guest to host, and a reverse channel for host - // to guest that is empty when the bundle declares no ingress. - EgressSocket string `protobuf:"bytes,1,opt,name=egress_socket,json=egressSocket,proto3" json:"egress_socket,omitempty"` - IngressSocket string `protobuf:"bytes,2,opt,name=ingress_socket,json=ingressSocket,proto3" json:"ingress_socket,omitempty"` - Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` +type OIDCSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Audience string `protobuf:"bytes,3,opt,name=audience,proto3" json:"audience,omitempty"` + RefreshToken string `protobuf:"bytes,4,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *AgentAttachResponse) Reset() { - *x = AgentAttachResponse{} - mi := &file_api_sam_proto_msgTypes[36] +func (x *OIDCSession) Reset() { + *x = OIDCSession{} + mi := &file_api_sam_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AgentAttachResponse) String() string { +func (x *OIDCSession) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AgentAttachResponse) ProtoMessage() {} +func (*OIDCSession) ProtoMessage() {} -func (x *AgentAttachResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[36] +func (x *OIDCSession) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2705,830 +2682,47 @@ func (x *AgentAttachResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AgentAttachResponse.ProtoReflect.Descriptor instead. -func (*AgentAttachResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{36} +// Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. +func (*OIDCSession) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{35} } -func (x *AgentAttachResponse) GetEgressSocket() string { +func (x *OIDCSession) GetIssuer() string { if x != nil { - return x.EgressSocket + return x.Issuer } return "" } -func (x *AgentAttachResponse) GetIngressSocket() string { +func (x *OIDCSession) GetClientId() string { if x != nil { - return x.IngressSocket + return x.ClientId } return "" } -func (x *AgentAttachResponse) GetError() string { +func (x *OIDCSession) GetAudience() string { if x != nil { - return x.Error + return x.Audience } return "" } -// AgentDetachRequest stops an agent: ingress is unregistered, channels are -// closed and credentials dropped. It must leave no residual advertisement. -type AgentDetachRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentDetachRequest) Reset() { - *x = AgentDetachRequest{} - mi := &file_api_sam_proto_msgTypes[37] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentDetachRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentDetachRequest) ProtoMessage() {} - -func (x *AgentDetachRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[37] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentDetachRequest.ProtoReflect.Descriptor instead. -func (*AgentDetachRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{37} -} - -func (x *AgentDetachRequest) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -type AgentDetachResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentDetachResponse) Reset() { - *x = AgentDetachResponse{} - mi := &file_api_sam_proto_msgTypes[38] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentDetachResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentDetachResponse) ProtoMessage() {} - -func (x *AgentDetachResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[38] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentDetachResponse.ProtoReflect.Descriptor instead. -func (*AgentDetachResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{38} -} - -func (x *AgentDetachResponse) GetSuccess() bool { - if x != nil { - return x.Success - } - return false -} - -func (x *AgentDetachResponse) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -// AgentRefreshRequest hands in a rotated workload credential. Platforms rotate -// these on their own schedule, which is what bounds how long a stale admission -// stays usable. -type AgentRefreshRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - CredentialPath string `protobuf:"bytes,2,opt,name=credential_path,json=credentialPath,proto3" json:"credential_path,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentRefreshRequest) Reset() { - *x = AgentRefreshRequest{} - mi := &file_api_sam_proto_msgTypes[39] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentRefreshRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentRefreshRequest) ProtoMessage() {} - -func (x *AgentRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[39] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentRefreshRequest.ProtoReflect.Descriptor instead. -func (*AgentRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{39} -} - -func (x *AgentRefreshRequest) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *AgentRefreshRequest) GetCredentialPath() string { - if x != nil { - return x.CredentialPath - } - return "" -} - -type AgentRefreshResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` - ExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentRefreshResponse) Reset() { - *x = AgentRefreshResponse{} - mi := &file_api_sam_proto_msgTypes[40] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentRefreshResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentRefreshResponse) ProtoMessage() {} - -func (x *AgentRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[40] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentRefreshResponse.ProtoReflect.Descriptor instead. -func (*AgentRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{40} -} - -func (x *AgentRefreshResponse) GetSuccess() bool { - if x != nil { - return x.Success - } - return false -} - -func (x *AgentRefreshResponse) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -func (x *AgentRefreshResponse) GetExpireTime() *timestamppb.Timestamp { - if x != nil { - return x.ExpireTime - } - return nil -} - -// AgentStatusRequest reports on one agent, or on all of them when agent_id is -// empty, for a scheduler's reconcile loop. -type AgentStatusRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentStatusRequest) Reset() { - *x = AgentStatusRequest{} - mi := &file_api_sam_proto_msgTypes[41] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentStatusRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentStatusRequest) ProtoMessage() {} - -func (x *AgentStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[41] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentStatusRequest.ProtoReflect.Descriptor instead. -func (*AgentStatusRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{41} -} - -func (x *AgentStatusRequest) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -type AgentStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - Attached bool `protobuf:"varint,2,opt,name=attached,proto3" json:"attached,omitempty"` - Ingress []*AgentIngress `protobuf:"bytes,3,rep,name=ingress,proto3" json:"ingress,omitempty"` - CredentialExpireTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=credential_expire_time,json=credentialExpireTime,proto3" json:"credential_expire_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentStatus) Reset() { - *x = AgentStatus{} - mi := &file_api_sam_proto_msgTypes[42] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentStatus) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentStatus) ProtoMessage() {} - -func (x *AgentStatus) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[42] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. -func (*AgentStatus) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{42} -} - -func (x *AgentStatus) GetAgentId() string { - if x != nil { - return x.AgentId - } - return "" -} - -func (x *AgentStatus) GetAttached() bool { - if x != nil { - return x.Attached - } - return false -} - -func (x *AgentStatus) GetIngress() []*AgentIngress { - if x != nil { - return x.Ingress - } - return nil -} - -func (x *AgentStatus) GetCredentialExpireTime() *timestamppb.Timestamp { - if x != nil { - return x.CredentialExpireTime - } - return nil -} - -type AgentStatusResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Agents []*AgentStatus `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents,omitempty"` - Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AgentStatusResponse) Reset() { - *x = AgentStatusResponse{} - mi := &file_api_sam_proto_msgTypes[43] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AgentStatusResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AgentStatusResponse) ProtoMessage() {} - -func (x *AgentStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[43] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AgentStatusResponse.ProtoReflect.Descriptor instead. -func (*AgentStatusResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{43} -} - -func (x *AgentStatusResponse) GetAgents() []*AgentStatus { - if x != nil { - return x.Agents - } - return nil -} - -func (x *AgentStatusResponse) GetError() string { - if x != nil { - return x.Error - } - return "" -} - -type IdentityEvidenceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` - Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` - BiscuitExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=biscuit_expire_time,json=biscuitExpireTime,proto3" json:"biscuit_expire_time,omitempty"` - ControlPlaneUrl string `protobuf:"bytes,4,opt,name=control_plane_url,json=controlPlaneUrl,proto3" json:"control_plane_url,omitempty"` - TrustedControlPlaneKeys [][]byte `protobuf:"bytes,5,rep,name=trusted_control_plane_keys,json=trustedControlPlaneKeys,proto3" json:"trusted_control_plane_keys,omitempty"` // Ed25519 SPKI DER - CheckTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=check_time,json=checkTime,proto3" json:"check_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *IdentityEvidenceResponse) Reset() { - *x = IdentityEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[44] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *IdentityEvidenceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*IdentityEvidenceResponse) ProtoMessage() {} - -func (x *IdentityEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[44] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use IdentityEvidenceResponse.ProtoReflect.Descriptor instead. -func (*IdentityEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{44} -} - -func (x *IdentityEvidenceResponse) GetPeerId() string { - if x != nil { - return x.PeerId - } - return "" -} - -func (x *IdentityEvidenceResponse) GetBiscuit() []byte { - if x != nil { - return x.Biscuit - } - return nil -} - -func (x *IdentityEvidenceResponse) GetBiscuitExpireTime() *timestamppb.Timestamp { - if x != nil { - return x.BiscuitExpireTime - } - return nil -} - -func (x *IdentityEvidenceResponse) GetControlPlaneUrl() string { - if x != nil { - return x.ControlPlaneUrl - } - return "" -} - -func (x *IdentityEvidenceResponse) GetTrustedControlPlaneKeys() [][]byte { - if x != nil { - return x.TrustedControlPlaneKeys - } - return nil -} - -func (x *IdentityEvidenceResponse) GetCheckTime() *timestamppb.Timestamp { - if x != nil { - return x.CheckTime - } - return nil -} - -type PeerEvidenceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PeerId string `protobuf:"bytes,1,opt,name=peer_id,json=peerId,proto3" json:"peer_id,omitempty"` - Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` - VerifyingKey []byte `protobuf:"bytes,3,opt,name=verifying_key,json=verifyingKey,proto3" json:"verifying_key,omitempty"` // Ed25519 SPKI DER, member of the trusted set - Roles []string `protobuf:"bytes,4,rep,name=roles,proto3" json:"roles,omitempty"` - Labels map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ExpireTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` - RevocationIds []string `protobuf:"bytes,7,rep,name=revocation_ids,json=revocationIds,proto3" json:"revocation_ids,omitempty"` // hex - CheckTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=check_time,json=checkTime,proto3" json:"check_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PeerEvidenceResponse) Reset() { - *x = PeerEvidenceResponse{} - mi := &file_api_sam_proto_msgTypes[45] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PeerEvidenceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PeerEvidenceResponse) ProtoMessage() {} - -func (x *PeerEvidenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[45] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PeerEvidenceResponse.ProtoReflect.Descriptor instead. -func (*PeerEvidenceResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{45} -} - -func (x *PeerEvidenceResponse) GetPeerId() string { - if x != nil { - return x.PeerId - } - return "" -} - -func (x *PeerEvidenceResponse) GetBiscuit() []byte { - if x != nil { - return x.Biscuit - } - return nil -} - -func (x *PeerEvidenceResponse) GetVerifyingKey() []byte { - if x != nil { - return x.VerifyingKey - } - return nil -} - -func (x *PeerEvidenceResponse) GetRoles() []string { - if x != nil { - return x.Roles - } - return nil -} - -func (x *PeerEvidenceResponse) GetLabels() map[string]string { - if x != nil { - return x.Labels - } - return nil -} - -func (x *PeerEvidenceResponse) GetExpireTime() *timestamppb.Timestamp { - if x != nil { - return x.ExpireTime - } - return nil -} - -func (x *PeerEvidenceResponse) GetRevocationIds() []string { - if x != nil { - return x.RevocationIds - } - return nil -} - -func (x *PeerEvidenceResponse) GetCheckTime() *timestamppb.Timestamp { - if x != nil { - return x.CheckTime - } - return nil -} - -type MemberCredential struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Base URL of the control plane that minted the biscuit. - ControlPlaneUrl string `protobuf:"bytes,1,opt,name=control_plane_url,json=controlPlaneUrl,proto3" json:"control_plane_url,omitempty"` - // The member's biscuit. - Biscuit []byte `protobuf:"bytes,2,opt,name=biscuit,proto3" json:"biscuit,omitempty"` - // When the biscuit expires. - ExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expire_time,json=expireTime,proto3" json:"expire_time,omitempty"` - // Control plane signing keys trusted now; a rotation keeps several valid. - TrustedKeys []*TrustedSigningKey `protobuf:"bytes,4,rep,name=trusted_keys,json=trustedKeys,proto3" json:"trusted_keys,omitempty"` - // The keys trusted when the biscuit was issued. A key trusted now that is - // absent here means a rotation happened since: the biscuit is signed by a - // retiring key and must be refreshed before that key leaves its grace - // period. - IssuedUnderKeys [][]byte `protobuf:"bytes,5,rep,name=issued_under_keys,json=issuedUnderKeys,proto3" json:"issued_under_keys,omitempty"` - // Router multiaddrs, `/p2p/` suffixed. - RouterAddresses []string `protobuf:"bytes,6,rep,name=router_addresses,json=routerAddresses,proto3" json:"router_addresses,omitempty"` - // The session that renews an identity enrolled through the mesh's - // identity provider. Unset for a member enrolled with a bootstrap token. - OidcSession *OIDCSession `protobuf:"bytes,7,opt,name=oidc_session,json=oidcSession,proto3" json:"oidc_session,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *MemberCredential) Reset() { - *x = MemberCredential{} - mi := &file_api_sam_proto_msgTypes[46] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *MemberCredential) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*MemberCredential) ProtoMessage() {} - -func (x *MemberCredential) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[46] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use MemberCredential.ProtoReflect.Descriptor instead. -func (*MemberCredential) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{46} -} - -func (x *MemberCredential) GetControlPlaneUrl() string { - if x != nil { - return x.ControlPlaneUrl - } - return "" -} - -func (x *MemberCredential) GetBiscuit() []byte { - if x != nil { - return x.Biscuit - } - return nil -} - -func (x *MemberCredential) GetExpireTime() *timestamppb.Timestamp { - if x != nil { - return x.ExpireTime - } - return nil -} - -func (x *MemberCredential) GetTrustedKeys() []*TrustedSigningKey { - if x != nil { - return x.TrustedKeys - } - return nil -} - -func (x *MemberCredential) GetIssuedUnderKeys() [][]byte { - if x != nil { - return x.IssuedUnderKeys - } - return nil -} - -func (x *MemberCredential) GetRouterAddresses() []string { - if x != nil { - return x.RouterAddresses - } - return nil -} - -func (x *MemberCredential) GetOidcSession() *OIDCSession { - if x != nil { - return x.OidcSession - } - return nil -} - -type TrustedSigningKey struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Raw ed25519 public key. - PublicKey []byte `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` - // When the member first learned the key. A key rotated out is dropped a - // grace period after this; unset means unknown and is read as now. - ReceiveTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=receive_time,json=receiveTime,proto3" json:"receive_time,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *TrustedSigningKey) Reset() { - *x = TrustedSigningKey{} - mi := &file_api_sam_proto_msgTypes[47] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *TrustedSigningKey) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*TrustedSigningKey) ProtoMessage() {} - -func (x *TrustedSigningKey) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[47] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use TrustedSigningKey.ProtoReflect.Descriptor instead. -func (*TrustedSigningKey) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{47} -} - -func (x *TrustedSigningKey) GetPublicKey() []byte { - if x != nil { - return x.PublicKey - } - return nil -} - -func (x *TrustedSigningKey) GetReceiveTime() *timestamppb.Timestamp { - if x != nil { - return x.ReceiveTime - } - return nil -} - -type OIDCSession struct { - state protoimpl.MessageState `protogen:"open.v1"` - Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` - ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` - Audience string `protobuf:"bytes,3,opt,name=audience,proto3" json:"audience,omitempty"` - RefreshToken string `protobuf:"bytes,4,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OIDCSession) Reset() { - *x = OIDCSession{} - mi := &file_api_sam_proto_msgTypes[48] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OIDCSession) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OIDCSession) ProtoMessage() {} - -func (x *OIDCSession) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[48] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OIDCSession.ProtoReflect.Descriptor instead. -func (*OIDCSession) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{48} -} - -func (x *OIDCSession) GetIssuer() string { - if x != nil { - return x.Issuer - } - return "" -} - -func (x *OIDCSession) GetClientId() string { - if x != nil { - return x.ClientId - } - return "" -} - -func (x *OIDCSession) GetAudience() string { - if x != nil { - return x.Audience - } - return "" -} - -func (x *OIDCSession) GetRefreshToken() string { - if x != nil { - return x.RefreshToken - } - return "" +func (x *OIDCSession) GetRefreshToken() string { + if x != nil { + return x.RefreshToken + } + return "" } var File_api_sam_proto protoreflect.FileDescriptor const file_api_sam_proto_rawDesc = "" + "\n" + - "\rapi/sam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"b\n" + + "\rapi/sam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"L\n" + "\tAuthFrame\x12\x18\n" + "\abiscuit\x18\x01 \x01(\fR\abiscuit\x12%\n" + - "\x0etarget_service\x18\x02 \x01(\tR\rtargetService\x12\x14\n" + - "\x05agent\x18\x03 \x01(\tR\x05agent\"X\n" + + "\x0etarget_service\x18\x02 \x01(\tR\rtargetService\"X\n" + "\fAuthResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12\x18\n" + @@ -3637,16 +2831,15 @@ const file_api_sam_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12;\n" + "\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expireTime\"\x90\x02\n" + + "expireTime\"\xe9\x01\n" + "\n" + "PolicyRole\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12'\n" + "\x0fallowed_targets\x18\x02 \x03(\tR\x0eallowedTargets\x12)\n" + "\x10allowed_services\x18\x03 \x03(\tR\x0fallowedServices\x12%\n" + "\x0ecustom_datalog\x18\x04 \x03(\tR\rcustomDatalog\x12%\n" + - "\x0eallowed_agents\x18\x05 \x03(\tR\rallowedAgents\x12%\n" + - "\x0eallowed_labels\x18\x06 \x03(\tR\rallowedLabels\x12%\n" + - "\x04http\x18\a \x03(\v2\x11.sam.v1.HTTPGrantR\x04http\"U\n" + + "\x0eallowed_labels\x18\x05 \x03(\tR\rallowedLabels\x12%\n" + + "\x04http\x18\x06 \x03(\v2\x11.sam.v1.HTTPGrantR\x04http\"U\n" + "\tHTTPGrant\x12\x18\n" + "\aservice\x18\x01 \x01(\tR\aservice\x12\x18\n" + "\amethods\x18\x02 \x03(\tR\amethods\x12\x14\n" + @@ -3666,9 +2859,9 @@ const file_api_sam_proto_rawDesc = "" + "\x05roles\x18\x01 \x03(\v2\x12.sam.v1.PolicyRoleR\x05roles\x121\n" + "\bbindings\x18\x02 \x03(\v2\x15.sam.v1.PolicyBindingR\bbindings\x121\n" + "\x06egress\x18\x03 \x03(\v2\x19.sam.v1.EgressDestinationR\x06egress\"\x18\n" + - "\x16PolicyConfigGetRequest\"[\n" + + "\x16PolicyConfigGetRequest\">\n" + "\x17PolicyConfigGetResponse\x12#\n" + - "\rdatalog_rules\x18\x03 \x03(\tR\fdatalogRulesJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x05rolesR\bbindings\"L\n" + + "\rdatalog_rules\x18\x01 \x03(\tR\fdatalogRules\"L\n" + "\x1aPolicyConfigUpdateResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\"\x1a\n" + @@ -3697,58 +2890,6 @@ const file_api_sam_proto_rawDesc = "" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\"E\n" + "\x13TokenRevokeResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"u\n" + - "\vAgentSecret\x12\x12\n" + - "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + - "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x1f\n" + - "\vheader_name\x18\x03 \x01(\tR\n" + - "headerName\x12\x1d\n" + - "\n" + - "value_path\x18\x04 \x01(\tR\tvaluePath\"R\n" + - "\vAgentEgress\x12\x14\n" + - "\x05allow\x18\x01 \x03(\tR\x05allow\x12-\n" + - "\asecrets\x18\x02 \x03(\v2\x13.sam.v1.AgentSecretR\asecrets\"\x81\x01\n" + - "\fAgentIngress\x12'\n" + - "\x04type\x18\x01 \x01(\x0e2\x13.sam.v1.ServiceTypeR\x04type\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + - "\x04port\x18\x03 \x01(\rR\x04port\x12 \n" + - "\vdescription\x18\x04 \x01(\tR\vdescription\"\xe9\x01\n" + - "\vAgentBundle\x12\x18\n" + - "\aversion\x18\x01 \x01(\tR\aversion\x12\x19\n" + - "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x1f\n" + - "\vexternal_id\x18\x03 \x01(\tR\n" + - "externalId\x12'\n" + - "\x0fcredential_path\x18\x04 \x01(\tR\x0ecredentialPath\x12+\n" + - "\x06egress\x18\x05 \x01(\v2\x13.sam.v1.AgentEgressR\x06egress\x12.\n" + - "\aingress\x18\x06 \x03(\v2\x14.sam.v1.AgentIngressR\aingress\"A\n" + - "\x12AgentAttachRequest\x12+\n" + - "\x06bundle\x18\x01 \x01(\v2\x13.sam.v1.AgentBundleR\x06bundle\"w\n" + - "\x13AgentAttachResponse\x12#\n" + - "\regress_socket\x18\x01 \x01(\tR\fegressSocket\x12%\n" + - "\x0eingress_socket\x18\x02 \x01(\tR\ringressSocket\x12\x14\n" + - "\x05error\x18\x03 \x01(\tR\x05error\"/\n" + - "\x12AgentDetachRequest\x12\x19\n" + - "\bagent_id\x18\x01 \x01(\tR\aagentId\"E\n" + - "\x13AgentDetachResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\"Y\n" + - "\x13AgentRefreshRequest\x12\x19\n" + - "\bagent_id\x18\x01 \x01(\tR\aagentId\x12'\n" + - "\x0fcredential_path\x18\x02 \x01(\tR\x0ecredentialPath\"\x83\x01\n" + - "\x14AgentRefreshResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error\x12;\n" + - "\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expireTime\"/\n" + - "\x12AgentStatusRequest\x12\x19\n" + - "\bagent_id\x18\x01 \x01(\tR\aagentId\"\xc6\x01\n" + - "\vAgentStatus\x12\x19\n" + - "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1a\n" + - "\battached\x18\x02 \x01(\bR\battached\x12.\n" + - "\aingress\x18\x03 \x03(\v2\x14.sam.v1.AgentIngressR\aingress\x12P\n" + - "\x16credential_expire_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x14credentialExpireTime\"X\n" + - "\x13AgentStatusResponse\x12+\n" + - "\x06agents\x18\x01 \x03(\v2\x13.sam.v1.AgentStatusR\x06agents\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\"\xbd\x02\n" + "\x18IdentityEvidenceResponse\x12\x17\n" + "\apeer_id\x18\x01 \x01(\tR\x06peerId\x12\x18\n" + @@ -3815,7 +2956,7 @@ func file_api_sam_proto_rawDescGZIP() []byte { } var file_api_sam_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 54) +var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 41) var file_api_sam_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: sam.v1.EnrollmentStatus (ServiceType)(0), // 1: sam.v1.ServiceType @@ -3851,78 +2992,56 @@ var file_api_sam_proto_goTypes = []any{ (*NodeCatalogReport)(nil), // 31: sam.v1.NodeCatalogReport (*TokenRevokeRequest)(nil), // 32: sam.v1.TokenRevokeRequest (*TokenRevokeResponse)(nil), // 33: sam.v1.TokenRevokeResponse - (*AgentSecret)(nil), // 34: sam.v1.AgentSecret - (*AgentEgress)(nil), // 35: sam.v1.AgentEgress - (*AgentIngress)(nil), // 36: sam.v1.AgentIngress - (*AgentBundle)(nil), // 37: sam.v1.AgentBundle - (*AgentAttachRequest)(nil), // 38: sam.v1.AgentAttachRequest - (*AgentAttachResponse)(nil), // 39: sam.v1.AgentAttachResponse - (*AgentDetachRequest)(nil), // 40: sam.v1.AgentDetachRequest - (*AgentDetachResponse)(nil), // 41: sam.v1.AgentDetachResponse - (*AgentRefreshRequest)(nil), // 42: sam.v1.AgentRefreshRequest - (*AgentRefreshResponse)(nil), // 43: sam.v1.AgentRefreshResponse - (*AgentStatusRequest)(nil), // 44: sam.v1.AgentStatusRequest - (*AgentStatus)(nil), // 45: sam.v1.AgentStatus - (*AgentStatusResponse)(nil), // 46: sam.v1.AgentStatusResponse - (*IdentityEvidenceResponse)(nil), // 47: sam.v1.IdentityEvidenceResponse - (*PeerEvidenceResponse)(nil), // 48: sam.v1.PeerEvidenceResponse - (*MemberCredential)(nil), // 49: sam.v1.MemberCredential - (*TrustedSigningKey)(nil), // 50: sam.v1.TrustedSigningKey - (*OIDCSession)(nil), // 51: sam.v1.OIDCSession - nil, // 52: sam.v1.EnrollRequest.LabelsEntry - nil, // 53: sam.v1.BootstrapEnrollRequest.LabelsEntry - nil, // 54: sam.v1.CommandBackend.EnvEntry - nil, // 55: sam.v1.ServiceAnnounce.LabelsEntry - nil, // 56: sam.v1.PeerEvidenceResponse.LabelsEntry - (*timestamppb.Timestamp)(nil), // 57: google.protobuf.Timestamp + (*IdentityEvidenceResponse)(nil), // 34: sam.v1.IdentityEvidenceResponse + (*PeerEvidenceResponse)(nil), // 35: sam.v1.PeerEvidenceResponse + (*MemberCredential)(nil), // 36: sam.v1.MemberCredential + (*TrustedSigningKey)(nil), // 37: sam.v1.TrustedSigningKey + (*OIDCSession)(nil), // 38: sam.v1.OIDCSession + nil, // 39: sam.v1.EnrollRequest.LabelsEntry + nil, // 40: sam.v1.BootstrapEnrollRequest.LabelsEntry + nil, // 41: sam.v1.CommandBackend.EnvEntry + nil, // 42: sam.v1.ServiceAnnounce.LabelsEntry + nil, // 43: sam.v1.PeerEvidenceResponse.LabelsEntry + (*timestamppb.Timestamp)(nil), // 44: google.protobuf.Timestamp } var file_api_sam_proto_depIdxs = []int32{ 2, // 0: sam.v1.MeshEvent.type:type_name -> sam.v1.MeshEvent.Type - 57, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp - 52, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry - 57, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp - 53, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry + 44, // 1: sam.v1.MeshEvent.event_time:type_name -> google.protobuf.Timestamp + 39, // 2: sam.v1.EnrollRequest.labels:type_name -> sam.v1.EnrollRequest.LabelsEntry + 44, // 3: sam.v1.EnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 40, // 4: sam.v1.BootstrapEnrollRequest.labels:type_name -> sam.v1.BootstrapEnrollRequest.LabelsEntry 0, // 5: sam.v1.BootstrapEnrollResponse.status:type_name -> sam.v1.EnrollmentStatus - 57, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp + 44, // 6: sam.v1.BootstrapEnrollResponse.expire_time:type_name -> google.protobuf.Timestamp 1, // 7: sam.v1.ServiceInfo.type:type_name -> sam.v1.ServiceType - 54, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry + 41, // 8: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry 10, // 9: sam.v1.RegisterServiceRequest.service:type_name -> sam.v1.ServiceInfo 11, // 10: sam.v1.RegisterServiceRequest.command:type_name -> sam.v1.CommandBackend 1, // 11: sam.v1.ServiceAnnounce.type:type_name -> sam.v1.ServiceType - 55, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry - 57, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp - 57, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp + 42, // 12: sam.v1.ServiceAnnounce.labels:type_name -> sam.v1.ServiceAnnounce.LabelsEntry + 44, // 13: sam.v1.ServiceAnnounce.announce_time:type_name -> google.protobuf.Timestamp + 44, // 14: sam.v1.RouterLeaseResponse.expire_time:type_name -> google.protobuf.Timestamp 19, // 15: sam.v1.PolicyRole.http:type_name -> sam.v1.HTTPGrant 18, // 16: sam.v1.PolicyConfig.roles:type_name -> sam.v1.PolicyRole 21, // 17: sam.v1.PolicyConfig.bindings:type_name -> sam.v1.PolicyBinding 20, // 18: sam.v1.PolicyConfig.egress:type_name -> sam.v1.EgressDestination 20, // 19: sam.v1.EgressAssignmentsResponse.egress:type_name -> sam.v1.EgressDestination - 57, // 20: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp - 57, // 21: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp + 44, // 20: sam.v1.KeysResponse.sign_time:type_name -> google.protobuf.Timestamp + 44, // 21: sam.v1.TokenRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp 10, // 22: sam.v1.NodeCatalogReport.services:type_name -> sam.v1.ServiceInfo - 34, // 23: sam.v1.AgentEgress.secrets:type_name -> sam.v1.AgentSecret - 1, // 24: sam.v1.AgentIngress.type:type_name -> sam.v1.ServiceType - 35, // 25: sam.v1.AgentBundle.egress:type_name -> sam.v1.AgentEgress - 36, // 26: sam.v1.AgentBundle.ingress:type_name -> sam.v1.AgentIngress - 37, // 27: sam.v1.AgentAttachRequest.bundle:type_name -> sam.v1.AgentBundle - 57, // 28: sam.v1.AgentRefreshResponse.expire_time:type_name -> google.protobuf.Timestamp - 36, // 29: sam.v1.AgentStatus.ingress:type_name -> sam.v1.AgentIngress - 57, // 30: sam.v1.AgentStatus.credential_expire_time:type_name -> google.protobuf.Timestamp - 45, // 31: sam.v1.AgentStatusResponse.agents:type_name -> sam.v1.AgentStatus - 57, // 32: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp - 57, // 33: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 56, // 34: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry - 57, // 35: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp - 57, // 36: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp - 57, // 37: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp - 50, // 38: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey - 51, // 39: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession - 57, // 40: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp - 41, // [41:41] is the sub-list for method output_type - 41, // [41:41] is the sub-list for method input_type - 41, // [41:41] is the sub-list for extension type_name - 41, // [41:41] is the sub-list for extension extendee - 0, // [0:41] is the sub-list for field type_name + 44, // 23: sam.v1.IdentityEvidenceResponse.biscuit_expire_time:type_name -> google.protobuf.Timestamp + 44, // 24: sam.v1.IdentityEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 43, // 25: sam.v1.PeerEvidenceResponse.labels:type_name -> sam.v1.PeerEvidenceResponse.LabelsEntry + 44, // 26: sam.v1.PeerEvidenceResponse.expire_time:type_name -> google.protobuf.Timestamp + 44, // 27: sam.v1.PeerEvidenceResponse.check_time:type_name -> google.protobuf.Timestamp + 44, // 28: sam.v1.MemberCredential.expire_time:type_name -> google.protobuf.Timestamp + 37, // 29: sam.v1.MemberCredential.trusted_keys:type_name -> sam.v1.TrustedSigningKey + 38, // 30: sam.v1.MemberCredential.oidc_session:type_name -> sam.v1.OIDCSession + 44, // 31: sam.v1.TrustedSigningKey.receive_time:type_name -> google.protobuf.Timestamp + 32, // [32:32] is the sub-list for method output_type + 32, // [32:32] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_api_sam_proto_init() } @@ -3940,7 +3059,7 @@ func file_api_sam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_sam_proto_rawDesc), len(file_api_sam_proto_rawDesc)), NumEnums: 3, - NumMessages: 54, + NumMessages: 41, NumExtensions: 0, NumServices: 0, }, diff --git a/api/sam.proto b/api/sam.proto index 1e6fe59c..0a4dfcaa 100644 --- a/api/sam.proto +++ b/api/sam.proto @@ -23,12 +23,6 @@ import "google/protobuf/timestamp.proto"; message AuthFrame { bytes biscuit = 1; string target_service = 2; // Optional: specific service requested - - // The agent this request is made for, as a canonical agent identifier (see - // api/agent.go). It is the calling node's claim, carried beside the token - // because Biscuit hides an appended block's facts from the authorizer; the - // HTTP datapath carries the same claim in HeaderSamAgent. - string agent = 3; } message AuthResponse { @@ -136,8 +130,7 @@ enum ServiceType { // A destination outside the mesh, reached through a node that enforces // policy on it. The service name is the destination hostname, so a grant // reads egress://api.github.com and the request fact - // service("egress", "api.github.com"). Egress names have no .sam.alt form: - // a sandboxed agent connects to the destination name itself. + // service("egress", "api.github.com"). SERVICE_TYPE_EGRESS = 4; } @@ -227,17 +220,12 @@ message PolicyRole { repeated string allowed_targets = 2; repeated string allowed_services = 3; repeated string custom_datalog = 4; - // Agent namespaces the holder may speak for, e.g. "*.prod.acme.example". - // An agent claim is the calling node's word, so it is only worth what the - // control plane attested about that node. Distinct from allowed_targets: - // being allowed to call an agent is not being allowed to impersonate it. - repeated string allowed_agents = 5; // Labels a node with this role may declare at enrollment, as "*", "key=*" // or "key=value". A node declares its own labels, so this is what turns a // declaration into something the control plane is willing to sign. - repeated string allowed_labels = 6; + repeated string allowed_labels = 5; // HTTP narrowing of allowed_services entries; see HTTPGrant. - repeated HTTPGrant http = 7; + repeated HTTPGrant http = 6; } // HTTPGrant narrows one allowed_services entry to HTTP methods and paths. @@ -301,12 +289,8 @@ message PolicyConfigGetRequest {} // biscuit. It carries the policy only as Datalog text: this is the contract // every member evaluates, and none derives rules from roles and bindings. message PolicyConfigGetResponse { - // roles and bindings once travelled here; they are the operator's document - // now (PolicyConfig). Reserved so an old sender is detected, not misread. - reserved 1, 2; - reserved "roles", "bindings"; // One rule per entry, rendered by the control plane with api.BuildPolicyRules. - repeated string datalog_rules = 3; + repeated string datalog_rules = 1; } message PolicyConfigUpdateResponse { @@ -378,126 +362,6 @@ message TokenRevokeResponse { string error = 2; } - -// ============================================================================ -// Agent Sandbox Connector API -// ============================================================================ -// -// The interface an agent scheduler (Agent Substrate, or any other) implements -// to place agents on the mesh. It is host-local and served on a control socket -// that is deliberately not reachable from inside a sandbox: identity must never -// arrive in band from the agent itself, which could only lie about it. - -// AgentSecret configures credential injection for one destination. It carries -// a path, never a value: secret material must not travel through this API. -message AgentSecret { - string host = 1; - string kind = 2; // bearer | basicauth | customheader - string header_name = 3; // customheader only - string value_path = 4; -} - -// AgentEgress is deny-by-default. Patterns are matched against the destination -// name taken from the sandbox boundary, never against a resolved address. -message AgentEgress { - repeated string allow = 1; - repeated AgentSecret secrets = 2; -} - -// AgentIngress declares that the agent serves a mesh service. The name is the -// service half of the mesh host the rest of the mesh dials (see api/names.go); -// port is where the agent listens inside its sandbox. -message AgentIngress { - ServiceType type = 1; - string name = 2; - uint32 port = 3; - string description = 4; -} - -// AgentBundle is everything the platform declares about one agent. Its -// canonical form is a YAML file in the agent's own state directory, so that a -// suspend/resume onto another host carries it with no extra machinery; this -// message is the transport mirror of that file. -message AgentBundle { - string version = 1; - - // Canonical mesh identifier, without the "agent:" prefix. Dot-separated and - // DNS-shaped; see api/agent.go for the rules and why they exist. - string agent_id = 2; - - // The platform's own identifier, verbatim, kept for audit because the - // translation into agent_id is not always reversible. - string external_id = 3; - - // Path to the workload credential the platform already issues: a projected - // Kubernetes service-account token, a pod certificate, or an SVID. It is - // verified at admission against the platform's issuer and then translated - // into agent facts, the same way OIDC claims are translated at node - // enrollment. The scheduler needs no mesh credential of its own. - string credential_path = 4; - - AgentEgress egress = 5; - repeated AgentIngress ingress = 6; -} - -// AgentAttachRequest admits an agent. It is idempotent on agent_id: resuming -// after a crash or a migration is another Attach, not a distinct operation. -message AgentAttachRequest { - AgentBundle bundle = 1; -} - -message AgentAttachResponse { - // Sandbox boundary endpoints to wire into the sandbox: named HTTP tunnels - // (CONNECT, connect-udp) for guest to host, and a reverse channel for host - // to guest that is empty when the bundle declares no ingress. - string egress_socket = 1; - string ingress_socket = 2; - string error = 3; -} - -// AgentDetachRequest stops an agent: ingress is unregistered, channels are -// closed and credentials dropped. It must leave no residual advertisement. -message AgentDetachRequest { - string agent_id = 1; -} - -message AgentDetachResponse { - bool success = 1; - string error = 2; -} - -// AgentRefreshRequest hands in a rotated workload credential. Platforms rotate -// these on their own schedule, which is what bounds how long a stale admission -// stays usable. -message AgentRefreshRequest { - string agent_id = 1; - string credential_path = 2; -} - -message AgentRefreshResponse { - bool success = 1; - string error = 2; - google.protobuf.Timestamp expire_time = 3; -} - -// AgentStatusRequest reports on one agent, or on all of them when agent_id is -// empty, for a scheduler's reconcile loop. -message AgentStatusRequest { - string agent_id = 1; -} - -message AgentStatus { - string agent_id = 1; - bool attached = 2; - repeated AgentIngress ingress = 3; - google.protobuf.Timestamp credential_expire_time = 4; -} - -message AgentStatusResponse { - repeated AgentStatus agents = 1; - string error = 2; -} - // ============================================================================ // Identity Evidence API // ============================================================================ diff --git a/api/validation.go b/api/validation.go index 27864bde..b8219502 100644 --- a/api/validation.go +++ b/api/validation.go @@ -136,9 +136,6 @@ func ValidateTargetFormat(target string) error { } // "*" as the fact matches every target_fact (granted_target_all_facts). if fact != "*" && !slices.Contains(TargetFactNames(), fact) { - if fact == FactAgent { - return fmt.Errorf("invalid target %q: an agent cannot be a target, because a node's identity does not say which agents it hosts. Use allowed_agents to grant the agent namespaces a node may act for", target) - } return fmt.Errorf("invalid target %q: %q is not a target fact, so nothing would ever match it (want %s or \"*\")", target, fact, strings.Join(TargetFactNames(), ", ")) } return nil diff --git a/cmd/nano-init/.gitignore b/cmd/nano-init/.gitignore deleted file mode 100644 index 8f3b2a4c..00000000 --- a/cmd/nano-init/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# `go build` in this directory drops the binary here; it belongs in bin/. -/nano-init diff --git a/cmd/nano-init/README.md b/cmd/nano-init/README.md deleted file mode 100644 index 226d1710..00000000 --- a/cmd/nano-init/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# nano-init - -PID 1 in an agent sandbox. It gives the sandbox one route, which leads to the -boundary, and then gets out of the agent's way. - -## What it does - -1. **Builds the only way out.** Creates `tun0` over netlink and gives it the - guest ends of the synthetic address pools (`100.64.0.0/10`, `100::/64`). - There is no other interface in the sandbox, so this is not the preferred - path out; it is the only one. -2. **Carries a TCP stack.** Terminates the sandbox's TCP/IP in userspace via - the [tun2connect](https://github.com/aojea/agents.net) library (gVisor's - netstack) and opens one named HTTP tunnel to the boundary per flow: - authority-form `CONNECT` for TCP, `connect-udp` for UDP. -3. **Keeps the name.** The virtual DNS answers with a synthetic address per - name and remembers the pairing, so what reaches the boundary is - `mesh.sam.alt` rather than an address. The boundary chooses a provider from - the name, which is the entire reason the name has to survive the trip. A - flow to an address the guest never resolved has no name, and is refused. -4. **PID 1 duties.** Reaps orphans, propagates `SIGINT`/`SIGTERM`/`SIGQUIT` to - the child's process group, and exits with the agent's own status. - -It is a separate Go module. A userspace TCP stack is a large dependency and has -no business in the graph every other SAM binary builds from. The datapath — -engine, tunnel client, virtual DNS — is the tun2connect library, consumed here -rather than forked: what this module owns is exactly the SAM- and -platform-specific part, the vsock boundary for microVMs, `--create-namespaces` -for pods, `copy` for image builds, and PID 1. - -## What it deliberately does not do - -It does not touch the agent. No `HTTP_PROXY` in its environment, no CA bundle -injected, nothing preloaded into its address space. - -That is a reversal. This program used to do all three, and argued for it: route -everything through an HTTP proxy, the reasoning went, because HTTP has -well-established ways to assert identity, and supporting arbitrary L3/L4 would -mean building a network stack. - -The objection is not that it was inelegant. It is that **every one of those -mechanisms is a request for the agent's cooperation.** `HTTP_PROXY` works if -the client library reads it. `LD_PRELOAD` works if the binary has a dynamic -loader. Both are outside the boundary the moment an agent uses a library that -ignores the convention, spawns a subprocess that clears its environment, or -speaks something that is not HTTP. An agent that has to cooperate with its own -confinement is not confined — and an agent driven by a model, acting on text it -did not write, is exactly the case where you cannot assume cooperation. - -Routing does not ask. The cost is the network stack the old rationale wanted to -avoid, which is why this uses gVisor's rather than writing one: retransmission, -windowing and teardown are easy to get subtly wrong, and the symptom is tail -latency under load. - -Name resolution is the one piece that looks like the old design and is not. The -resolver here is a convenience for clients that look a name up before -connecting; it is not a control. An agent that ignores it and hardcodes another -resolver has its packets routed through the tun regardless, and reaches exactly -what policy allows. - -## Usage - -```bash -nano-init run [args...] -``` - -```bash -nano-init run /run/agent.sock python agent.py "summarise the open issues" -``` - -Needs `NET_ADMIN` and `/dev/net/tun` to build the tun. In a container: - -```bash -docker run --rm --network none \ - --cap-add NET_ADMIN --device /dev/net/tun \ - -v /run/sam/agent.sock:/run/agent.sock \ - my-agent-image -``` - -`nano-init copy ` writes the binary somewhere else, for building a sandbox -image that has nothing else in it. - -## See also - -- [Sandboxed agents](https://sam-mesh.dev/docs/preview/sandboxed-agents/) — the - full picture, including the microVM arrangement -- [Agent architecture](https://sam-mesh.dev/docs/preview/agent-architecture/) — why the - boundary speaks named HTTP tunnels diff --git a/cmd/nano-init/copy.go b/cmd/nano-init/copy.go deleted file mode 100644 index b33e0415..00000000 --- a/cmd/nano-init/copy.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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. - -package main - -import ( - "io" - "os" -) - -func copyFile(src, dest string) error { - in, err := os.Open(src) // #nosec G304 -- the caller names its own binary - if err != nil { - return err - } - defer func() { _ = in.Close() }() - - out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) // #nosec G302,G304 - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - return out.Close() -} diff --git a/cmd/nano-init/go.mod b/cmd/nano-init/go.mod deleted file mode 100644 index ef70fdb3..00000000 --- a/cmd/nano-init/go.mod +++ /dev/null @@ -1,22 +0,0 @@ -module github.com/google/sam/cmd/nano-init - -go 1.26.6 - -require ( - github.com/aojea/agents.net/tun2connect v0.0.1 - github.com/mdlayher/vsock v1.3.0 - github.com/vishvananda/netlink v1.3.1 - golang.org/x/sys v0.48.0 -) - -require ( - github.com/google/btree v1.1.3 // indirect - github.com/mdlayher/socket v0.6.0 // indirect - github.com/vishvananda/netns v0.0.5 // indirect - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/net v0.59.0 // indirect - golang.org/x/sync v0.23.0 // indirect - golang.org/x/text v0.42.0 // indirect - golang.org/x/time v0.16.0 // indirect - gvisor.dev/gvisor v0.0.0-20260827233944-c21d56e7d544 // indirect -) diff --git a/cmd/nano-init/go.sum b/cmd/nano-init/go.sum deleted file mode 100644 index 0ef26100..00000000 --- a/cmd/nano-init/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -github.com/aojea/agents.net/tun2connect v0.0.1 h1:Q/uj1UrZhJIyaSrxmxYMFBz/b8mmACHlTT60PpPIh2I= -github.com/aojea/agents.net/tun2connect v0.0.1/go.mod h1:zTkUfO7BHJTjeGYdCiGXKQ2N+fy9IZkUwDW36fi+hJY= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU= -github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= -github.com/mdlayher/vsock v1.3.0 h1:bqQfZ1OznI03y6YiXp2sze05RVdzLn/zsfjnjd4+ivI= -github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQTzxxNY= -github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= -github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= -github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= -github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= -gvisor.dev/gvisor v0.0.0-20260827233944-c21d56e7d544 h1:AfKDPn5UNs9npLCLMfQzaKOAM++WQw0e9v0p0aT5OUc= -gvisor.dev/gvisor v0.0.0-20260827233944-c21d56e7d544/go.mod h1:8aLQqUBHDH8fY5y60lzmwDpMMbQCcT3EBfoSwhfaGCY= diff --git a/cmd/nano-init/ingress.go b/cmd/nano-init/ingress.go deleted file mode 100644 index 6de57352..00000000 --- a/cmd/nano-init/ingress.go +++ /dev/null @@ -1,196 +0,0 @@ -// 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. - -package main - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "log" - "net" - "os" - "strconv" - "strings" - "sync" - "time" -) - -// An agent that serves the mesh needs traffic delivered to it, and delivery is -// the direction the sandbox is built to prevent. The gateway cannot dial the -// agent: a 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 every -// profile -- a microVM, a container with no network, and a pod where nano-init -// made the namespace itself -- because it is a consequence of the isolation -// rather than of any one runtime. -// -// The way out is the way in. Egress already crosses the boundary over a -// pathname Unix socket, which network namespaces do not apply to because it is -// a filesystem object. So the reverse channel is another one, listened on by -// this process, which is inside the namespace and can therefore reach the -// agent at the address the gateway meant. -// -// The handshake is Firecracker's, deliberately: connect, send "CONNECT ", -// read "OK". A microVM can offer the identical protocol over vsock without the -// gateway learning the difference. - -const ( - ingressConnectTimeout = 10 * time.Second - // A port is at most five digits and a line at most one, so anything longer - // is a client that has misunderstood. - ingressMaxHandshake = 64 -) - -// serveIngress accepts the gateway's inbound connections and joins each one to -// the port the agent serves. -func serveIngress(ctx context.Context, socketPath string) error { - listener, err := net.Listen("unix", socketPath) - if err != nil { - return fmt.Errorf("listen on the ingress socket %s: %w", socketPath, err) - } - // This socket relays to any port inside the sandbox with no credential of - // its own, so its permissions are the credential. Failing closed rather - // than serving it world-writable, like the node and sam-box sockets. - if err := os.Chmod(socketPath, 0600); err != nil { - _ = listener.Close() - return fmt.Errorf("restrict access to the ingress socket %s: %w", socketPath, err) - } - go func() { - <-ctx.Done() - _ = listener.Close() - }() - - log.Printf("serving ingress on %s", socketPath) - for { - conn, err := listener.Accept() - if err != nil { - if ctx.Err() != nil { - return nil - } - return fmt.Errorf("accept on the ingress socket: %w", err) - } - go func() { - if err := handleIngress(ctx, conn); err != nil { - log.Printf("ingress connection: %v", err) - } - }() - } -} - -// handleIngress reads which port the gateway is asking for and connects it. -func handleIngress(ctx context.Context, conn net.Conn) error { - defer func() { _ = conn.Close() }() - - _ = conn.SetReadDeadline(time.Now().Add(ingressConnectTimeout)) - reader := bufio.NewReaderSize(conn, ingressMaxHandshake) - // ReadSlice rather than ReadString: ReadString grows a buffer of its own - // until it finds a newline, so the size above would bound nothing, and a - // client that never sends one could make this process -- PID 1 in the - // sandbox -- accumulate for as long as the deadline allows. ReadSlice - // stops at the buffer and says so. The far side of this handshake bounds - // its read the same way; see dialSandbox in internal/sambox/ingress.go. - line, err := reader.ReadSlice('\n') - if err != nil { - if errors.Is(err, bufio.ErrBufferFull) { - return refuseIngress(conn, fmt.Errorf("the handshake is longer than %d bytes", ingressMaxHandshake)) - } - return fmt.Errorf("read the ingress handshake: %w", err) - } - _ = conn.SetReadDeadline(time.Time{}) - - port, err := parseIngressConnect(string(line)) - if err != nil { - return refuseIngress(conn, err) - } - - // The agent is in this namespace, which is the whole reason this hop - // exists: here 127.0.0.1 means what the gateway intended. - dialer := net.Dialer{Timeout: ingressConnectTimeout} - agent, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) - if err != nil { - _, _ = io.WriteString(conn, "ERR the agent is not listening\n") - return fmt.Errorf("dial the agent on port %d: %w", port, err) - } - defer func() { _ = agent.Close() }() - - if _, err := io.WriteString(conn, "OK\n"); err != nil { - return fmt.Errorf("acknowledge the ingress handshake: %w", err) - } - - // Anything the gateway sent after the handshake is already buffered. - if n := reader.Buffered(); n > 0 { - pending, err := reader.Peek(n) - if err != nil { - return fmt.Errorf("recover buffered request bytes: %w", err) - } - if _, err := agent.Write(pending); err != nil { - return fmt.Errorf("forward buffered request bytes: %w", err) - } - } - - relay(conn, agent) - return nil -} - -// refuseIngress tells the gateway why its handshake was not honoured. -// -// Answered rather than dropped: a gateway that gets nothing back cannot tell -// a refusal from a sandbox that never started. -func refuseIngress(conn net.Conn, err error) error { - _, _ = io.WriteString(conn, "ERR "+err.Error()+"\n") - return err -} - -// parseIngressConnect reads the one line the gateway sends first. -func parseIngressConnect(line string) (int, error) { - fields := strings.Fields(strings.TrimSpace(line)) - if len(fields) != 2 || !strings.EqualFold(fields[0], "CONNECT") { - return 0, fmt.Errorf("expected \"CONNECT \"") - } - port, err := strconv.Atoi(fields[1]) - if err != nil || port < 1 || port > 65535 { - return 0, fmt.Errorf("%q is not a port", fields[1]) - } - return port, nil -} - -// relay joins two connections until either end is done with the other. -func relay(a, b net.Conn) { - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - _, _ = io.Copy(a, b) - closeWrite(a) - }() - go func() { - defer wg.Done() - _, _ = io.Copy(b, a) - closeWrite(b) - }() - wg.Wait() -} - -// closeWrite ends one direction so the far side sees EOF, falling back to a -// full close for connections that cannot half-close. -func closeWrite(c net.Conn) { - type closeWriter interface{ CloseWrite() error } - if cw, ok := c.(closeWriter); ok { - _ = cw.CloseWrite() - return - } - _ = c.Close() -} diff --git a/cmd/nano-init/ingress_test.go b/cmd/nano-init/ingress_test.go deleted file mode 100644 index ccb73476..00000000 --- a/cmd/nano-init/ingress_test.go +++ /dev/null @@ -1,237 +0,0 @@ -// 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. - -package main - -import ( - "bufio" - "bytes" - "context" - "io" - "net" - "os" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -// TestIngressSocketIsOwnerOnly pins the permissions on the one hole punched -// through the sandbox boundary. The ingress socket relays "CONNECT " to -// any port inside the namespace with no token and no capability of its own, so -// its mode is the only thing standing between a neighbouring process and the -// agent. Both sibling sockets in this repo are 0600 for the same reason; the -// umask that would otherwise decide this is not ours to assume. -func TestIngressSocketIsOwnerOnly(t *testing.T) { - // Loosen the umask so a missing chmod really would leave the socket group- - // and world-accessible, rather than being masked into passing. - old := syscall.Umask(0) - defer syscall.Umask(old) - - socketPath := filepath.Join(t.TempDir(), "ingress.sock") - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - served := make(chan error, 1) - go func() { served <- serveIngress(ctx, socketPath) }() - - deadline := time.Now().Add(2 * time.Second) - var info os.FileInfo - for { - var err error - if info, err = os.Stat(socketPath); err == nil { - break - } - if time.Now().After(deadline) { - t.Fatalf("ingress socket %s never appeared: %v", socketPath, err) - } - time.Sleep(5 * time.Millisecond) - } - - if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("ingress socket permissions are %#o, want 0600", perm) - } - - cancel() - select { - case err := <-served: - if err != nil { - t.Errorf("serveIngress returned %v, want nil on cancellation", err) - } - case <-time.After(2 * time.Second): - t.Error("serveIngress did not return after cancellation") - } -} - -// floodConn is a client that opens the ingress socket and then never sends the -// newline the handshake ends with. It counts what the handler reads, so a test -// can hold the handler to the bound the package documents. -type floodConn struct { - read int - limit int - reply bytes.Buffer -} - -func (c *floodConn) Read(p []byte) (int, error) { - if c.read >= c.limit { - return 0, io.EOF - } - n := len(p) - if remaining := c.limit - c.read; n > remaining { - n = remaining - } - for i := range p[:n] { - p[i] = 'A' - } - c.read += n - return n, nil -} - -func (c *floodConn) Write(p []byte) (int, error) { return c.reply.Write(p) } -func (c *floodConn) Close() error { return nil } -func (c *floodConn) LocalAddr() net.Addr { return floodAddr{} } -func (c *floodConn) RemoteAddr() net.Addr { return floodAddr{} } -func (c *floodConn) SetDeadline(time.Time) error { return nil } -func (c *floodConn) SetReadDeadline(time.Time) error { return nil } -func (c *floodConn) SetWriteDeadline(time.Time) error { return nil } - -type floodAddr struct{} - -func (floodAddr) Network() string { return "flood" } -func (floodAddr) String() string { return "flood" } - -// TestHandleIngressBoundsTheHandshake holds the handshake read to the size the -// package names. ingressMaxHandshake sizes a bufio.Reader, and that bounds only -// what one fill holds: ReadString goes on growing a buffer of its own until it -// finds a newline, so a client that sends none could make this process -- PID 1 -// in the sandbox -- accumulate for as long as the read deadline allows. -func TestHandleIngressBoundsTheHandshake(t *testing.T) { - const flood = 1 << 20 - conn := &floodConn{limit: flood} - - err := handleIngress(context.Background(), conn) - if err == nil { - t.Fatal("handleIngress accepted a handshake with no newline, want an error") - } - if got := conn.read; got > ingressMaxHandshake { - t.Errorf("handleIngress read %d bytes of a %d byte flood, want at most %d", got, flood, ingressMaxHandshake) - } - if answer := conn.reply.String(); !strings.HasPrefix(answer, "ERR ") { - t.Errorf("handleIngress answered %q, want an ERR line: a gateway that gets nothing back cannot tell a refusal from a sandbox that never started", answer) - } -} - -// TestHandleIngressRelaysPipelinedBytes covers what a bounded read must not -// break. The gateway may send its first request bytes in the same write as the -// handshake, and those are in the reader rather than the socket by the time the -// agent is dialled, so they are forwarded by hand. -func TestHandleIngressRelaysPipelinedBytes(t *testing.T) { - const pipelined = "HELLO" - - agent, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen as the agent: %v", err) - } - defer func() { _ = agent.Close() }() - _, port, err := net.SplitHostPort(agent.Addr().String()) - if err != nil { - t.Fatalf("split the agent address: %v", err) - } - - delivered := make(chan string, 1) - go func() { - c, err := agent.Accept() - if err != nil { - delivered <- "accept: " + err.Error() - return - } - defer func() { _ = c.Close() }() - buf := make([]byte, len(pipelined)) - if _, err := io.ReadFull(c, buf); err != nil { - delivered <- "read: " + err.Error() - return - } - delivered <- string(buf) - }() - - client, server := net.Pipe() - defer func() { _ = client.Close() }() - done := make(chan error, 1) - go func() { done <- handleIngress(context.Background(), server) }() - go func() { _, _ = io.WriteString(client, "CONNECT "+port+"\n"+pipelined) }() - - reply, err := bufio.NewReader(io.LimitReader(client, 128)).ReadString('\n') - if err != nil { - t.Fatalf("read the handshake answer: %v", err) - } - if got := strings.TrimSpace(reply); got != "OK" { - t.Fatalf("the handshake answer is %q, want OK", got) - } - - select { - case got := <-delivered: - if got != pipelined { - t.Errorf("the agent received %q, want %q", got, pipelined) - } - case <-time.After(5 * time.Second): - t.Fatal("the agent never received the bytes pipelined behind the handshake") - } - - select { - case <-done: - case <-time.After(5 * time.Second): - t.Error("handleIngress did not return once both ends were done") - } -} - -// TestParseIngressConnect pins the one line the gateway sends first. Everything -// past it is relayed verbatim, so this is where a malformed request has to stop. -func TestParseIngressConnect(t *testing.T) { - for _, tc := range []struct { - name string - line string - want int - }{ - {name: "port", line: "CONNECT 8080\n", want: 8080}, - {name: "lowercase verb", line: "connect 8080\n", want: 8080}, - {name: "extra spaces", line: " CONNECT 8080 \n", want: 8080}, - {name: "lowest port", line: "CONNECT 1\n", want: 1}, - {name: "highest port", line: "CONNECT 65535\n", want: 65535}, - {name: "port zero", line: "CONNECT 0\n"}, - {name: "above the port range", line: "CONNECT 65536\n"}, - {name: "negative", line: "CONNECT -1\n"}, - {name: "not a number", line: "CONNECT http\n"}, - {name: "wrong verb", line: "GET 8080\n"}, - {name: "no port", line: "CONNECT\n"}, - {name: "trailing junk", line: "CONNECT 8080 now\n"}, - {name: "empty", line: "\n"}, - } { - t.Run(tc.name, func(t *testing.T) { - got, err := parseIngressConnect(tc.line) - if tc.want == 0 { - if err == nil { - t.Fatalf("parseIngressConnect(%q) = %d, want an error", tc.line, got) - } - return - } - if err != nil { - t.Fatalf("parseIngressConnect(%q) returned %v, want %d", tc.line, err, tc.want) - } - if got != tc.want { - t.Errorf("parseIngressConnect(%q) = %d, want %d", tc.line, got, tc.want) - } - }) - } -} diff --git a/cmd/nano-init/isolation.go b/cmd/nano-init/isolation.go deleted file mode 100644 index 930593be..00000000 --- a/cmd/nano-init/isolation.go +++ /dev/null @@ -1,148 +0,0 @@ -// 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. - -package main - -import ( - "errors" - "fmt" - "os" - "strings" - "syscall" - - "github.com/vishvananda/netlink" -) - -// This binary builds the only route out of a sandbox. It does not build the -// sandbox: `docker run --network none` and a microVM's own kernel each hand it -// a network namespace with nowhere to go, and it fills in the way out. -// -// That assumption is worth checking rather than trusting, because when it does -// not hold nothing complains. Started in a namespace that already has an -// interface -- a Kubernetes pod, where every container shares one, or a plain -// `docker run` where somebody forgot the flag -- it would add tun0 alongside -// the existing device, add a second default route, and hand the agent a -// sandbox that is not one. The agent would be confined to a network it can -// route around, and the run would look like every successful run. -// -// So the precondition is enforced here, once, whatever created the namespace. - -// assertIsolated reports whether this network namespace is a sandbox. -func assertIsolated() error { - links, err := netlink.LinkList() - if err != nil { - return fmt.Errorf("list interfaces: %w", err) - } - names := make([]string, 0, len(links)) - for _, l := range links { - names = append(names, l.Attrs().Name) - } - return isolationError(names) -} - -// isolationError names the interfaces that mean this is not a sandbox. -// -// Loopback is expected and carries no traffic off the namespace. tun0 is our -// own, which matters because the device outlives the process that made it, so -// a second run in the same namespace must read as "already set up" rather than -// as "not isolated". -func isolationError(links []string) error { - var foreign []string - for _, name := range links { - if name == "lo" || name == tunName { - continue - } - foreign = append(foreign, name) - } - if len(foreign) == 0 { - return nil - } - return fmt.Errorf( - "this network namespace has %s, so it is not a sandbox: the agent could route around the boundary. "+ - "Give it a namespace of its own -- `docker run --network none`, or a microVM with no network device", - strings.Join(foreign, ", "), - ) -} - -// tunDevice is the clone device every tun is created through. Its absence and -// its permissions are two different problems with two different fixes, which -// is the whole reason for the diagnosis below. -const tunDevice = "/dev/net/tun" - -// userNSRestriction is Ubuntu's switch for what an unprivileged user namespace -// may do. -const userNSRestriction = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns" - -// userNSCapabilitiesRestricted reports whether this host takes back the -// capabilities a user namespace would otherwise grant. -// -// Where it is on, a process with no AppArmor profile that creates a user -// namespace is confined to the unprivileged_userns profile, and that profile -// denies every capability. Nothing fails at the time: the namespace is created, -// and the capability inside it is refused later. -func userNSCapabilitiesRestricted() bool { - value, err := os.ReadFile(userNSRestriction) - return err == nil && strings.TrimSpace(string(value)) != "0" -} - -// describeTunFailure turns a netlink error into the thing to change. -func describeTunFailure(err error) string { - _, statErr := os.Stat(tunDevice) - return tunHint(err, statErr, userNSCapabilitiesRestricted()) -} - -// tunHint explains a failed tun creation. -// -// The old message asked whether the kernel had CONFIG_TUN, which is the right -// question in a microVM and useless in a container, where the same failure -// means the device was not passed in or the capability was not granted. The -// profiles fail differently, so they are told apart here rather than left to -// whoever is reading a log at the time. -func tunHint(err error, statErr error, restrictedUserNS bool) string { - switch { - case os.IsNotExist(statErr): - return "there is no " + tunDevice + ". In a microVM that means a guest kernel built without CONFIG_TUN" + - " (the stock Firecracker CI kernels carry vsock but no tun driver; 6.18.41 has it)." + - " In a container it means the device was not passed in: `--device /dev/net/tun` for docker," + - " or a hostPath volume of type CharDevice for a Kubernetes pod" - - case os.IsPermission(statErr): - return tunDevice + " exists but cannot be opened. Check the device's own permissions, and any" + - " device cgroup or seccomp policy the runtime applies; note that a user namespace does not" + - " help here, because opening the device is checked against the host and not the namespace" - - case errors.Is(err, syscall.EPERM), errors.Is(err, syscall.EACCES), - // netlink formats this one rather than wrapping the errno, so there is - // nothing for errors.Is to match on. - err != nil && strings.Contains(err.Error(), "TUNSETIFF"): - // Named first because the advice below is wrong here: the capability - // was granted and then taken away again, so granting it harder is no - // answer. - if restrictedUserNS { - return "creating a tun was refused, and this host restricts what an unprivileged user namespace may do" + - " (" + userNSRestriction + " is not 0): a process with no AppArmor profile that creates one is" + - " confined to the unprivileged_userns profile, which denies every capability -- so the namespace" + - " was created and CAP_NET_ADMIN in it was refused anyway. Give the sandbox an AppArmor profile" + - " that permits capabilities, or set that sysctl to 0" - } - return "creating a tun was refused. It needs CAP_NET_ADMIN in the user namespace that owns this network" + - " namespace: a user namespace of your own grants it over the namespaces it owns, which is what" + - " --create-namespaces relies on; otherwise `--cap-add NET_ADMIN` for docker, or" + - " securityContext.capabilities.add: [NET_ADMIN] for a pod" - - case errors.Is(err, syscall.ENODEV): - return "the kernel has no tun driver. Rebuild the guest kernel with CONFIG_TUN=y" - } - return "the tun device could not be created, and the cause is not one this knows how to explain" -} diff --git a/cmd/nano-init/isolation_test.go b/cmd/nano-init/isolation_test.go deleted file mode 100644 index 2223e363..00000000 --- a/cmd/nano-init/isolation_test.go +++ /dev/null @@ -1,173 +0,0 @@ -// 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. - -package main - -import ( - "errors" - "io/fs" - "strings" - "syscall" - "testing" -) - -func TestTunHint(t *testing.T) { - notExist := &fs.PathError{Op: "stat", Path: tunDevice, Err: syscall.ENOENT} - denied := &fs.PathError{Op: "stat", Path: tunDevice, Err: syscall.EACCES} - - for _, tc := range []struct { - name string - err error - statErr error - // restricted is a host that hands back a user namespace and then - // denies the capabilities it would have granted. - restricted bool - // want is a phrase naming the fix, so the test fails when the advice - // stops matching the cause rather than only when the wording changes. - want string - }{ - { - name: "a guest kernel with no tun driver names CONFIG_TUN", - err: syscall.ENODEV, - statErr: notExist, - want: "CONFIG_TUN", - }, - { - name: "a container missing the device is told to pass it in", - err: syscall.ENOENT, - statErr: notExist, - want: "--device /dev/net/tun", - }, - { - name: "a pod missing the device is pointed at a hostPath", - err: syscall.ENOENT, - statErr: notExist, - want: "hostPath", - }, - { - name: "a device that cannot be opened is not blamed on capabilities", - statErr: denied, - err: syscall.EPERM, - want: "cannot be opened", - }, - { - name: "a refused create names the capability", - err: syscall.EPERM, - want: "CAP_NET_ADMIN", - }, - { - // The capability advice is worse than useless here: it was granted - // by the namespace and taken back by the host. - name: "a host that strips a user namespace's capabilities names the sysctl", - err: syscall.EPERM, - restricted: true, - want: "apparmor_restrict_unprivileged_userns", - }, - { - name: "a device that cannot be opened is still not blamed on the sysctl", - err: syscall.EPERM, - statErr: denied, - restricted: true, - want: "cannot be opened", - }, - { - name: "EACCES is treated as EPERM is", - err: syscall.EACCES, - want: "CAP_NET_ADMIN", - }, - { - name: "the kernel driver case is named even when the device exists", - err: syscall.ENODEV, - want: "CONFIG_TUN=y", - }, - { - name: "an unrecognised cause says so rather than guessing", - err: errors.New("something else entirely"), - want: "not one this knows how to explain", - }, - } { - t.Run(tc.name, func(t *testing.T) { - got := tunHint(tc.err, tc.statErr, tc.restricted) - if !strings.Contains(got, tc.want) { - t.Errorf("tunHint(%v, %v, %v) = %q, want it to mention %q", tc.err, tc.statErr, tc.restricted, got, tc.want) - } - }) - } -} - -func TestIsolationError(t *testing.T) { - for _, tc := range []struct { - name string - links []string - wantErr bool - // names is what the message must mention, so an operator is told which - // interface is the problem rather than only that there is one. - names []string - }{ - { - name: "a microVM with no network device", - links: []string{"lo"}, - }, - { - name: "a container run with --network none", - links: []string{"lo"}, - }, - { - name: "our own tun, from an earlier run in this namespace", - links: []string{"lo", tunName}, - }, - { - name: "a Kubernetes pod, where the namespace is shared", - links: []string{"lo", "eth0"}, - wantErr: true, - names: []string{"eth0"}, - }, - { - name: "a docker run that forgot --network none", - links: []string{"lo", "eth0", tunName}, - wantErr: true, - names: []string{"eth0"}, - }, - { - name: "several ways out are all reported", - links: []string{"lo", "eth0", "vlan7"}, - wantErr: true, - names: []string{"eth0", "vlan7"}, - }, - { - // A namespace with nothing at all is not one we built, but it has - // no way out either, which is the only property being asserted. - name: "an empty namespace", - links: nil, - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := isolationError(tc.links) - if tc.wantErr && err == nil { - t.Fatalf("isolationError(%q) = nil, want an error: an agent here is not confined", tc.links) - } - if !tc.wantErr { - if err != nil { - t.Fatalf("isolationError(%q) = %v, want nil", tc.links, err) - } - return - } - for _, name := range tc.names { - if !strings.Contains(err.Error(), name) { - t.Errorf("error does not name %q, so it does not say what to fix: %v", name, err) - } - } - }) - } -} diff --git a/cmd/nano-init/main.go b/cmd/nano-init/main.go deleted file mode 100644 index 4ab7ac91..00000000 --- a/cmd/nano-init/main.go +++ /dev/null @@ -1,422 +0,0 @@ -// 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. - -// Command nano-init is PID 1 in an agent sandbox. -// -// It gives the sandbox one route, which leads to the boundary, and then gets -// out of the agent's way. -// -// It used to do the opposite. It rewrote /etc/resolv.conf to point at a DNS -// server it ran itself, answered lookups with addresses it invented, injected -// HTTP_PROXY and friends into the agent's environment, and preloaded a shared -// object into the agent's address space to catch the connections that got past -// all that. Every one of those asks the agent to cooperate, and an agent that -// has to cooperate with its own confinement is not confined: the next library -// that ignores the proxy variables, the next subprocess that clears its -// environment, the next static binary with no loader to preload into, each one -// was outside the boundary. -// -// Routing does not ask. There is no interface in this sandbox except the tun, -// and the tun goes to the boundary, so an agent that ignores every convention -// here still reaches only what policy allowed. The resolver that remains is a -// convenience for clients that look a name up before connecting, not a control: -// an agent that resolves some other way is routed through the tun regardless. -// -// The datapath is the tun2connect library: gVisor's TCP stack terminating the -// sandbox's flows in userspace, each one leaving for the boundary as a named -// HTTP CONNECT (RFC 9110) or connect-udp (RFC 9298) tunnel, with a virtual DNS -// preserving the name the agent asked for. Writing a TCP stack here would mean -// writing retransmission, windowing and teardown, and getting those subtly -// wrong shows up as tail latency under load, which is exactly where this has -// to be trusted. -package main - -import ( - "context" - "errors" - "fmt" - "log" - "net" - "os" - "os/exec" - "os/signal" - "strings" - "syscall" - "time" - - "github.com/vishvananda/netlink" - "golang.org/x/sys/unix" - - "github.com/aojea/agents.net/tun2connect/pkg/tun2connect" -) - -const ( - tunName = "tun0" - tunMTU = 1500 - - // The guest addresses sit at the TOP of tun2connect's synthetic pools: - // the virtual DNS invents answers from the bottom up, so they can never - // collide with one. The /10 and /64 prefix lengths make the kernel - // install connected routes covering every synthetic address, so no - // explicit route entries are needed. - // - // v4 is CGNAT space (RFC 6598) rather than the link-local range this used - // to number from: link-local would be leak-proof at the first router, but - // SSRF guards in HTTP clients commonly block 169.254/16, which broke - // legitimate egress. v6 is the RFC 6666 discard-only prefix, so a packet - // that ever escapes through a stray interface is blackholed rather than - // delivered. - tunAddr4 = "100.127.255.254/10" - tunAddr6 = "100::ffff:ffff:ffff:fffe/64" - - // The resolver's address is any pool address routed through the tun: the - // engine answers UDP port 53 locally wherever the query is sent, so it - // needs no route or listener of its own. - resolverIP = "100.127.255.253" -) - -func main() { - if len(os.Args) < 2 { - usage() - } - - switch os.Args[1] { - case "copy": - if len(os.Args) != 3 { - log.Fatalf("usage: %s copy ", os.Args[0]) - } - src, err := os.Executable() - if err != nil { - src = "/nano-init" - } - if err := copyFile(src, os.Args[2]); err != nil { - log.Fatalf("copy binary: %v", err) - } - - case "run": - createNS, ingressSocket, args := parseRunFlags(os.Args[2:]) - if len(args) < 2 { - usage() - } - run(createNS, ingressSocket, args[0], args[1], args[2:]) - - default: - usage() - } -} - -// parseRunFlags reads our own flags and stops at the first argument that is not -// one, because everything after that belongs to the agent and must reach it -// untouched. -func parseRunFlags(args []string) (createNS bool, ingressSocket string, rest []string) { - for len(args) > 0 { - switch { - case args[0] == "--create-namespaces": - createNS, args = true, args[1:] - case args[0] == "--ingress-socket": - if len(args) < 2 { - log.Fatalf("--ingress-socket needs a path") - } - ingressSocket, args = args[1], args[2:] - case strings.HasPrefix(args[0], "--ingress-socket="): - ingressSocket, args = strings.TrimPrefix(args[0], "--ingress-socket="), args[1:] - default: - return createNS, ingressSocket, args - } - } - return createNS, ingressSocket, nil -} - -// runFlags rebuilds the arguments for the re-executed half, so it is given -// what this one was given. -func runFlags(ingressSocket, boundarySocket, cmdName string, cmdArgs []string) []string { - args := []string{"run", "--create-namespaces"} - if ingressSocket != "" { - args = append(args, "--ingress-socket", ingressSocket) - } - args = append(args, boundarySocket, cmdName) - return append(args, cmdArgs...) -} - -func usage() { - log.Fatalf("usage:\n %s copy \n %s run [--create-namespaces] [--ingress-socket ] [args...]", - os.Args[0], os.Args[0]) -} - -// run wires the sandbox up and hands it to the agent. -func run(createNS bool, ingressSocket, boundarySocket, cmdName string, cmdArgs []string) { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) - defer cancel() - - // The namespaces have to exist before anything is checked in them, and a - // whole Go program can only enter a new network namespace by being started - // in one. So this half makes them and becomes a supervisor; the half that - // comes back through here does the work. - if createNS && !insideCreatedNamespaces() { - userNS, err := needUserNamespace() - if err != nil { - log.Fatalf("refusing to start: %v", err) - } - // Decided out here, where /etc/resolv.conf is the one the runtime gave - // this container: if it already names our resolver there is nothing to - // mount, and asking for a mount namespace would only invite a denial. - mountNS := !resolvConfAlreadyOurs() - self, err := os.Executable() - if err != nil { - log.Fatalf("locate this binary to re-execute it: %v", err) - } - args := runFlags(ingressSocket, boundarySocket, cmdName, cmdArgs) - code, err := runAgent(ctx, cancel, self, args, withNamespaces(userNS, mountNS)) - if err != nil { - log.Fatalf("create the sandbox namespaces: %v\n%s", err, namespaceHint(err)) - } - os.Exit(code) - } - - if createNS { - if err := privateResolvConf(); err != nil { - log.Fatalf("refusing to start: %v", err) - } - } - - // First, and before anything is built: if this namespace is not a sandbox - // then the boundary is beside the point, and saying so in that order is - // the difference between "you forgot --network none" and a puzzling - // complaint about a socket. - if err := assertIsolated(); err != nil { - log.Fatalf("refusing to start: %v", err) - } - - if err := checkBoundary(boundarySocket); err != nil { - log.Fatalf("this sandbox has no way out: %v", err) - } - - if err := setupNetwork(ctx, boundarySocket); err != nil { - log.Fatalf("set up sandbox network: %v", err) - } - - // Started here rather than earlier because it only makes sense once the - // sandbox exists: this is the one process that can reach the agent at the - // address the gateway will name. - if ingressSocket != "" { - go func() { - if err := serveIngress(ctx, ingressSocket); err != nil { - log.Printf("ingress: %v", err) - } - }() - } - - code, err := runAgent(ctx, cancel, cmdName, cmdArgs) - if err != nil { - log.Fatalf("start agent: %v", err) - } - os.Exit(code) -} - -// setupNetwork builds the only route out of the sandbox. -// -// This talks netlink rather than shelling out to `ip`, and carries its own TCP -// stack rather than running a separate binary, so a sandbox image can be the -// agent and nothing else. That is not tidiness: image size is what decides how -// many agents fit on a host. -func setupNetwork(ctx context.Context, boundarySocket string) error { - // As PID 1 in a microVM nothing else has done this, and a sandbox without - // loopback breaks things that have no business caring about the network. - if lo, err := netlink.LinkByName("lo"); err == nil { - _ = netlink.LinkSetUp(lo) - } - - fd, err := openTUN(tunName) - if err != nil { - return fmt.Errorf("create %s: %w\n%s", tunName, err, describeTunFailure(err)) - } - - link, err := netlink.LinkByName(tunName) - if err != nil { - return fmt.Errorf("find %s after creating it: %w", tunName, err) - } - for _, cidr := range []string{tunAddr4, tunAddr6} { - addr, err := netlink.ParseAddr(cidr) - if err != nil { - return fmt.Errorf("parse %s: %w", cidr, err) - } - if err := netlink.AddrAdd(link, addr); err != nil { - return fmt.Errorf("address %s with %s: %w", tunName, cidr, err) - } - } - if err := netlink.LinkSetUp(link); err != nil { - return fmt.Errorf("bring up %s: %w", tunName, err) - } - - // Default routes with no gateway: nothing on the far side of this link has - // an address worth naming, and everything goes the same way regardless. - // The connected /10 and /64 routes already cover every synthetic address, - // but the default is what keeps the promise that routing does not ask: an - // agent that hardcodes its own resolver still has the query answered by - // the engine, and a stray dial to a literal address terminates at the - // boundary as a visible refusal rather than a kernel errno. The - // destination has to be spelled out rather than left nil, which netlink - // reads as "no route specified at all". - for _, dst := range []*net.IPNet{ - {IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, - {IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, - } { - if err := netlink.RouteAdd(&netlink.Route{ - LinkIndex: link.Attrs().Index, - Scope: netlink.SCOPE_LINK, - Dst: dst, - }); err != nil { - return fmt.Errorf("default route for %s via %s: %w", dst, tunName, err) - } - } - - // A pod can mount the file over instead, in which case it is read-only and - // already says this. - if !resolvConfAlreadyOurs() { - if err := os.WriteFile("/etc/resolv.conf", []byte("nameserver "+resolverIP+"\n"), 0o644); err != nil { - // Not fatal: resolution is a convenience here, not the control. - log.Printf("could not write /etc/resolv.conf, name resolution may fail: %v", err) - } - } - - dev, err := tun2connect.NewTUNDevice(fd, tunMTU) - if err != nil { - return fmt.Errorf("link endpoint on %s: %w", tunName, err) - } - engine, err := tun2connect.New(tun2connect.Config{ - Device: dev, - Dialer: &tun2connect.BoundaryClient{ - DialBoundary: func(ctx context.Context) (net.Conn, error) { - return dialBoundary(ctx, boundarySocket) - }, - }, - DNS: tun2connect.NewVirtualDNS(), - EnableUDP: true, - }) - if err != nil { - return fmt.Errorf("start the userspace TCP stack: %w", err) - } - go func() { - <-ctx.Done() - engine.Close() - }() - return nil -} - -// openTUN opens the clone device and names the interface. The fd is what the -// engine reads and writes; the interface is what the kernel routes into. -func openTUN(name string) (int, error) { - fd, err := unix.Open(tunDevice, unix.O_RDWR, 0) - if err != nil { - return -1, fmt.Errorf("open %s: %w", tunDevice, err) - } - ifr, err := unix.NewIfreq(name) - if err != nil { - _ = unix.Close(fd) - return -1, err - } - ifr.SetUint16(unix.IFF_TUN | unix.IFF_NO_PI) - if err := unix.IoctlIfreq(fd, unix.TUNSETIFF, ifr); err != nil { - _ = unix.Close(fd) - return -1, fmt.Errorf("TUNSETIFF %s: %w", name, err) - } - if err := unix.SetNonblock(fd, true); err != nil { - _ = unix.Close(fd) - return -1, err - } - return fd, nil -} - -// runAgent starts the agent and reports the exit status it should be judged by. -// -// The same supervision serves the namespace trampoline, whose child is this -// binary again: orphans still reparent here and still have to be reaped, and -// the exit code still has to be the one the caller sees. -func runAgent(ctx context.Context, cancel context.CancelFunc, cmdName string, cmdArgs []string, opts ...func(*exec.Cmd)) (int, error) { - cmd := exec.CommandContext(ctx, cmdName, cmdArgs...) - cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin - cmd.Env = os.Environ() // Nothing injected: the agent is not configured, it is routed. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - for _, opt := range opts { - opt(cmd) - } - cmd.Cancel = func() error { - if cmd.Process == nil { - return nil - } - return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) - } - cmd.WaitDelay = 5 * time.Second - - if err := cmd.Start(); err != nil { - // Returned rather than fatal: the namespace trampoline starts this same - // binary, and a refusal there means something quite different. - return 0, err - } - - // As PID 1 this process inherits every orphan in the sandbox, so it has to - // reap them or the guest fills with zombies. Reaping also means Wait can - // lose the race for the agent's own status, hence the channel. - agentExit := make(chan syscall.WaitStatus, 1) - reapChildren(cmd.Process.Pid, agentExit) - - waitErr := cmd.Wait() - cancel() - - if waitErr != nil && errors.Is(waitErr, syscall.ECHILD) { - status := <-agentExit - if status.Signaled() { - return 128 + int(status.Signal()), nil - } - return status.ExitStatus(), nil - } - if waitErr != nil { - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - return exitErr.ExitCode(), nil - } - return 1, nil - } - return 0, nil -} - -// reapChildren collects orphans and remembers the agent's own status. -func reapChildren(agentPid int, exitChan chan<- syscall.WaitStatus) { - sigCh := make(chan os.Signal, 10) - signal.Notify(sigCh, syscall.SIGCHLD) - - go func() { - reap := func() { - for { - var status syscall.WaitStatus - pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil) - if pid <= 0 || err != nil { - return - } - if pid == agentPid { - select { - case exitChan <- status: - default: - } - } - } - } - // Once before waiting on signals, to catch anything that exited - // between Start and Notify. - reap() - for range sigCh { - reap() - } - }() -} diff --git a/cmd/nano-init/main_test.go b/cmd/nano-init/main_test.go deleted file mode 100644 index 9fcc87c0..00000000 --- a/cmd/nano-init/main_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// 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. - -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// Name preservation — the property that mesh.sam.alt reaches the boundary as -// a NAME, because the boundary chooses a provider from it — lives in the -// tun2connect library now, and is pinned by that library's own tests. What -// remains here is what nano-init still owns: the two ways of naming a -// boundary, the agent's untouched environment, and the copy mode. - -func TestTheAgentEnvironmentIsNotDoctored(t *testing.T) { - // The point of the rewrite: nano-init no longer reaches into the agent. If - // these come back, confinement has quietly become a request for the - // agent's cooperation again and every argument for the design stops - // holding. - source, err := os.ReadFile("main.go") - if err != nil { - t.Fatalf("read main.go: %v", err) - } - for _, forbidden := range []string{ - "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", - "LD_PRELOAD", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", - } { - if strings.Contains(string(source), `"`+forbidden+`"`) { - t.Errorf("%s is being set for the agent again", forbidden) - } - } -} - -func TestTheBoundaryCanBeNamedEitherWay(t *testing.T) { - // A container dials a path and a microVM dials vsock. One binary serves - // both, and nothing else in the sandbox knows which kind it is, so this - // string is the entire difference between them. - if _, _, err := parseVsock("2:1080"); err != nil { - t.Errorf("parseVsock(2:1080): %v", err) - } - for _, bad := range []string{"2", "host:1080", "2:not-a-port", ""} { - if _, _, err := parseVsock(bad); err == nil { - t.Errorf("parseVsock(%q) was accepted", bad) - } - } - - // A missing socket has to be reported at startup. A sandbox that starts - // without a way out looks like a mesh outage on the agent's first call. - if err := checkBoundary(filepath.Join(t.TempDir(), "absent.sock")); err == nil { - t.Error("a boundary socket that does not exist was accepted") - } - if err := checkBoundary("vsock://2:1080"); err != nil { - t.Errorf("a well-formed vsock boundary was rejected: %v", err) - } -} - -func TestCopyFile(t *testing.T) { - dir := t.TempDir() - src := filepath.Join(dir, "src") - dest := filepath.Join(dir, "dest") - - if err := os.WriteFile(src, []byte("binary"), 0o600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if err := copyFile(src, dest); err != nil { - t.Fatalf("copyFile: %v", err) - } - - got, err := os.ReadFile(dest) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if string(got) != "binary" { - t.Errorf("contents = %q, want %q", got, "binary") - } - - info, err := os.Stat(dest) - if err != nil { - t.Fatalf("Stat: %v", err) - } - // The copy has to be runnable; the exact bits are the umask's business. - if info.Mode().Perm()&0o100 == 0 { - t.Errorf("mode = %v, want the owner execute bit set", info.Mode().Perm()) - } -} diff --git a/cmd/nano-init/namespaces.go b/cmd/nano-init/namespaces.go deleted file mode 100644 index 89528e35..00000000 --- a/cmd/nano-init/namespaces.go +++ /dev/null @@ -1,266 +0,0 @@ -// 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. - -package main - -import ( - "errors" - "fmt" - "os" - "os/exec" - "strconv" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -// Creating the sandbox is normally somebody else's job: `docker run -// --network none` and a microVM's own kernel both hand this process a -// namespace with nowhere to go. A Kubernetes pod does not, and cannot -- every -// container in a pod shares one network namespace, and the resolv.conf the -// kubelet writes is shared with them too -- so for that profile the namespaces -// have to be made here, from inside the container. -// -// This is opt-in rather than automatic. A sandbox that quietly creates its own -// isolation when it cannot find any is a sandbox that never reports a -// misconfigured runtime, and the two profiles that do get isolation from their -// runtime should keep failing loudly when it is missing. - -// nsCreatedEnv marks the re-executed child, so it sets the namespaces up once -// rather than forever. -const nsCreatedEnv = "NANO_INIT_NAMESPACES_CREATED" - -// capSysAdmin is the capability that creating a network namespace requires. -const capSysAdmin = 21 - -// insideCreatedNamespaces reports whether this process is the re-executed half. -func insideCreatedNamespaces() bool { - return os.Getenv(nsCreatedEnv) == "1" -} - -// withNamespaces makes the child the first process in a new network namespace, -// adding a mount namespace when the sandbox needs one and a user namespace when -// that is the only way to be allowed. -// -// The work happens in a child because unshare(CLONE_NEWNET) moves one thread, -// and the Go runtime has several that goroutines migrate between: the only way -// to get a whole program into a new network namespace is to start one there. -// -// The mount namespace is conditional because asking for one is not free. The -// runtime marks / private when Unshareflags carries CLONE_NEWNS, and that mount -// is exactly what containerd's default AppArmor profile denies -- so a sandbox -// that was handed a resolv.conf of its own, and therefore has nothing to mount, -// runs under that profile untouched by asking for no mount namespace at all. -func withNamespaces(userNS, mountNS bool) func(*exec.Cmd) { - return func(c *exec.Cmd) { - if c.SysProcAttr == nil { - c.SysProcAttr = &syscall.SysProcAttr{} - } - c.SysProcAttr.Cloneflags |= syscall.CLONE_NEWNET - if mountNS { - // Unshareflags rather than Cloneflags: the runtime also makes / - // private that way, so a bind mount below cannot propagate back to - // the pod. - c.SysProcAttr.Unshareflags |= syscall.CLONE_NEWNS - } - - if userNS { - c.SysProcAttr.Cloneflags |= syscall.CLONE_NEWUSER - c.SysProcAttr.UidMappings = []syscall.SysProcIDMap{ - {ContainerID: 0, HostID: os.Getuid(), Size: 1}, - } - c.SysProcAttr.GidMappings = []syscall.SysProcIDMap{ - {ContainerID: 0, HostID: os.Getgid(), Size: 1}, - } - // Denied because an unprivileged user namespace may not call it, - // and nothing in a sandbox needs supplementary groups. - c.SysProcAttr.GidMappingsEnableSetgroups = false - } - - c.Env = append(c.Env, nsCreatedEnv+"=1") - } -} - -// needUserNamespace decides how to get permission to create a network -// namespace, or explains why neither way is open. -// -// A user namespace is preferred rather than merely tolerated. Inside one this -// process is root over the namespaces it then creates, which supplies -// CAP_NET_ADMIN for building the tun as well as CAP_SYS_ADMIN for making the -// namespace at all. Taking the capability route instead needs both to have been -// granted: a container given CAP_SYS_ADMIN but not CAP_NET_ADMIN creates the -// namespace and then cannot build the route out of it, which is a worse failure -// than not starting. -// -// So the capability route is the fallback, for hosts where user namespaces are -// turned off. -func needUserNamespace() (bool, error) { - userNSErr := userNamespacesAvailable() - if userNSErr == nil { - return true, nil - } - has, err := hasCapSysAdmin() - if err != nil { - return false, err - } - if has { - return false, nil - } - return false, fmt.Errorf( - "cannot create a network namespace: %w, and this process has no CAP_SYS_ADMIN. "+ - "Allow unprivileged user namespaces, or grant CAP_SYS_ADMIN and CAP_NET_ADMIN", userNSErr) -} - -// namespaceHint explains a refusal to create the namespaces. -// -// The kernel says EPERM and stops there, but in a container the cause is -// usually a sandboxing policy rather than a missing capability, and those are -// not visible from in here. -func namespaceHint(err error) string { - if !errors.Is(err, syscall.EPERM) && !errors.Is(err, syscall.EACCES) { - return "" - } - return "This is usually the runtime's own sandboxing rather than a missing capability. " + - "Docker's default seccomp profile blocks creating a user namespace, and its default " + - "AppArmor profile blocks the mount that follows; Kubernetes applies neither unless asked, " + - "so a pod normally needs no securityContext for this at all. Where a profile is enforced, " + - "it has to permit unshare(CLONE_NEWUSER|CLONE_NEWNS) and mount." -} - -// hasCapSysAdmin reads the effective capability set of this process. -func hasCapSysAdmin() (bool, error) { - status, err := os.ReadFile("/proc/self/status") - if err != nil { - return false, fmt.Errorf("read capabilities: %w", err) - } - return capSysAdminFromStatus(string(status)) -} - -// capSysAdminFromStatus finds CAP_SYS_ADMIN in the CapEff line of a -// /proc//status. -func capSysAdminFromStatus(status string) (bool, error) { - for _, line := range strings.Split(status, "\n") { - hex, ok := strings.CutPrefix(line, "CapEff:") - if !ok { - continue - } - caps, err := strconv.ParseUint(strings.TrimSpace(hex), 16, 64) - if err != nil { - return false, fmt.Errorf("parse CapEff %q: %w", strings.TrimSpace(hex), err) - } - return caps&(1<_". Both arrive at the same sam-box, which is why one binary -// serves both and nothing else in the sandbox knows which kind it is. -// -// The vsock socket work is a library rather than forty lines of syscalls here. -// It was forty lines of syscalls, and they were wrong: net.FileConn refuses an -// AF_VSOCK descriptor outright, and the replacement leaned on os.File's poller -// registration for deadlines, which degrades silently to no deadlines at all if -// registration fails. Both are the kind of mistake that surfaces as a hung flow -// under load rather than an error at startup. This module already carries a -// TCP stack, so a thousand lines of well-exercised socket handling is not the -// dependency worth economising on. - -const vsockScheme = "vsock://" - -// boundaryDialTimeout bounds opening one flow to the boundary; established -// flows carry no deadline. -const boundaryDialTimeout = 30 * time.Second - -// dialBoundary opens a connection to the boundary named by spec, which is -// either "vsock://:" or a Unix socket path. -func dialBoundary(ctx context.Context, spec string) (net.Conn, error) { - if !strings.HasPrefix(spec, vsockScheme) { - return (&net.Dialer{Timeout: boundaryDialTimeout}).DialContext(ctx, "unix", spec) - } - - cid, port, err := parseVsock(strings.TrimPrefix(spec, vsockScheme)) - if err != nil { - return nil, err - } - // There is no context-aware Dial, and none is needed: the peer is the - // hypervisor on the other side of a virtual bus, so this either succeeds - // or fails immediately rather than waiting on anything that could hang. - return vsock.Dial(cid, port, nil) -} - -// checkBoundary reports whether the boundary named by spec could plausibly be -// reached, so a sandbox with no way out says so at startup rather than on the -// agent's first request. -func checkBoundary(spec string) error { - if strings.HasPrefix(spec, vsockScheme) { - if _, _, err := parseVsock(strings.TrimPrefix(spec, vsockScheme)); err != nil { - return err - } - // Whether the host is listening cannot be known without connecting, - // and connecting here would consume a flow the agent has not asked for. - return nil - } - if _, err := os.Stat(spec); err != nil { - return fmt.Errorf("no boundary at %s: %w", spec, err) - } - return nil -} - -func parseVsock(hostPort string) (cid, port uint32, err error) { - rawCID, rawPort, found := strings.Cut(hostPort, ":") - if !found { - return 0, 0, fmt.Errorf("vsock boundary %q is not :", hostPort) - } - - parsedCID, err := strconv.ParseUint(rawCID, 10, 32) - if err != nil { - return 0, 0, fmt.Errorf("vsock cid %q: %w", rawCID, err) - } - parsedPort, err := strconv.ParseUint(rawPort, 10, 32) - if err != nil { - return 0, 0, fmt.Errorf("vsock port %q: %w", rawPort, err) - } - return uint32(parsedCID), uint32(parsedPort), nil -} diff --git a/cmd/nano-init/vsock_test.go b/cmd/nano-init/vsock_test.go deleted file mode 100644 index a8535b69..00000000 --- a/cmd/nano-init/vsock_test.go +++ /dev/null @@ -1,186 +0,0 @@ -// 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. - -package main - -import ( - "context" - "errors" - "io" - "net" - "testing" - "time" - - "golang.org/x/sys/unix" -) - -// The vsock path is the microVM's only way out, and it is the one part of this -// program that cannot be exercised by the container sandbox. Left untested it -// would be discovered on a cloud VM in the middle of a scale run, which is an -// expensive place to find out that a socket option is wrong. -// -// Linux can loop vsock back to the host, so none of this needs a VM: the guest -// side of a real Firecracker connection makes the same calls against the same -// kernel code. - -// vsockLoopback is VMADDR_CID_LOCAL, the CID that means "this machine". -const vsockLoopback = 1 - -func TestDialBoundaryOverVsock(t *testing.T) { - listener, port := listenVsock(t) - defer func() { _ = unix.Close(listener) }() - - accepted := make(chan []byte, 1) - go func() { - fd, _, err := unix.Accept(listener) - if err != nil { - accepted <- nil - return - } - defer func() { _ = unix.Close(fd) }() - - buf := make([]byte, 5) - if _, err := unix.Read(fd, buf); err != nil { - accepted <- nil - return - } - _, _ = unix.Write(fd, []byte("PONG")) - accepted <- buf - }() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - conn, err := dialBoundary(ctx, vsockSpec(port)) - if err != nil { - t.Fatalf("dialBoundary over vsock: %v", err) - } - defer func() { _ = conn.Close() }() - - if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { - t.Fatalf("SetDeadline: %v", err) - } - if _, err := conn.Write([]byte("HELLO")); err != nil { - t.Fatalf("write: %v", err) - } - - reply := make([]byte, 4) - if _, err := io.ReadFull(conn, reply); err != nil { - t.Fatalf("read: %v", err) - } - if string(reply) != "PONG" { - t.Errorf("reply = %q, want PONG", reply) - } - - if got := <-accepted; string(got) != "HELLO" { - t.Errorf("boundary received %q, want HELLO", got) - } -} - -func TestVsockConnectionIsAUsableNetConn(t *testing.T) { - // The connection is handed to gVisor as an ordinary net.Conn and spliced - // against sandbox traffic, so deadlines and Close have to work. A raw - // descriptor wrapped carelessly satisfies the interface and then blocks - // forever on a read that should have timed out. - listener, port := listenVsock(t) - defer func() { _ = unix.Close(listener) }() - - go func() { - fd, _, err := unix.Accept(listener) - if err != nil { - return - } - // Accept and then say nothing, so the read below has to time out. - time.Sleep(3 * time.Second) - _ = unix.Close(fd) - }() - - conn, err := dialBoundary(context.Background(), vsockSpec(port)) - if err != nil { - t.Fatalf("dialBoundary: %v", err) - } - defer func() { _ = conn.Close() }() - - if err := conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { - t.Fatalf("SetReadDeadline: %v", err) - } - _, err = conn.Read(make([]byte, 1)) - if err == nil { - t.Fatal("a read with an expired deadline returned no error") - } - var timeout net.Error - if !errors.As(err, &timeout) || !timeout.Timeout() { - t.Errorf("read error = %v, want a timeout", err) - } -} - -func TestDialBoundaryReportsAnAbsentVsocklistener(t *testing.T) { - // A microVM whose host is not listening must fail loudly. Hanging would - // look to the agent like a slow mesh rather than an absent one. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Port 1 is not something this test ever binds. - if _, err := dialBoundary(ctx, "vsock://1:1"); err == nil { - t.Error("dialling a vsock port nobody listens on returned no error") - } -} - -// listenVsock binds a loopback vsock listener and returns it with its port. -func listenVsock(t *testing.T) (fd, port int) { - t.Helper() - - fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) - if err != nil { - t.Skipf("no AF_VSOCK on this kernel: %v", err) - } - - // Port 0 asks the kernel to choose, so concurrent runs cannot collide. - if err := unix.Bind(fd, &unix.SockaddrVM{CID: unix.VMADDR_CID_ANY, Port: 0}); err != nil { - _ = unix.Close(fd) - t.Skipf("cannot bind vsock (is vsock_loopback loaded?): %v", err) - } - if err := unix.Listen(fd, 1); err != nil { - _ = unix.Close(fd) - t.Skipf("cannot listen on vsock: %v", err) - } - - sa, err := unix.Getsockname(fd) - if err != nil { - _ = unix.Close(fd) - t.Fatalf("Getsockname: %v", err) - } - vm, ok := sa.(*unix.SockaddrVM) - if !ok { - _ = unix.Close(fd) - t.Fatalf("Getsockname returned %T, want *unix.SockaddrVM", sa) - } - return fd, int(vm.Port) -} - -func vsockSpec(port int) string { - return "vsock://" + itoa(vsockLoopback) + ":" + itoa(port) -} - -func itoa(v int) string { - if v == 0 { - return "0" - } - var digits []byte - for v > 0 { - digits = append([]byte{byte('0' + v%10)}, digits...) - v /= 10 - } - return string(digits) -} diff --git a/cmd/sam-box/main.go b/cmd/sam-box/main.go deleted file mode 100644 index 9b3d4ccc..00000000 --- a/cmd/sam-box/main.go +++ /dev/null @@ -1,261 +0,0 @@ -// 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. - -// Command sam-box is the sandbox dataplane: one per agent sandbox, serving the -// boundary an agent's traffic leaves through. -// -// It holds no libp2p host, no enrollment and no mesh identity of its own. It -// consumes a local sam-node over that node's API socket and offers the sandbox -// a curated surface: mesh inference and tools addressed by name, plus whatever -// egress policy allows. The node's own API stays on the node's side of the -// boundary. -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - - golog "github.com/ipfs/go-log/v2" - "github.com/spf13/cobra" - - "github.com/google/sam/api" - "github.com/google/sam/internal/sambox" -) - -var logger = golog.Logger("sam-box") - -func main() { - var ( - sandboxSocket string - sidecarSocket string - bundlePath string - egressAllow []string - issuer string - audience string - insecure bool - metricsAddr string - logLevel string - - agentIngressSocket string - ingressListen string - ) - - rootCmd := &cobra.Command{ - Use: "sam-box", - Short: "Sovereign Agent Mesh sandbox gateway", - } - - runCmd := &cobra.Command{ - Use: "run", - Short: "Serve the sandbox boundary for an agent", - Long: "Serves named HTTP tunnels (CONNECT for TCP, connect-udp for UDP) on a\n" + - "sandbox-facing Unix socket, so an unmodified agent reaches mesh inference and\n" + - "tools by name, and reaches nothing else unless egress policy allows it.", - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - golog.SetAllLoggers(golog.LevelInfo) - if lvl, err := golog.LevelFromString(logLevel); err == nil { - golog.SetAllLoggers(lvl) - } - - agentID, egress, err := resolveAgent(bundlePath, egressAllow, cmd.Flags().Changed("egress-allow")) - if err != nil { - return err - } - if err := verifyBundleCredential(cmd.Context(), bundlePath, issuer, audience, insecure); err != nil { - return err - } - ingress, err := resolveIngress(bundlePath, ingressListen, agentIngressSocket) - if err != nil { - return err - } - if ingress != nil { - defer ingress.Close() - } - - listener, err := sambox.ListenSandboxSocket(sandboxSocket) - if err != nil { - return err - } - defer func() { - _ = listener.Close() - _ = os.Remove(sandboxSocket) - }() - - if metricsAddr != "" { - if _, err := sambox.ServeMetrics(cmd.Context(), metricsAddr); err != nil { - return fmt.Errorf("serve metrics: %w", err) - } - logger.Infof("Serving metrics on http://%s/metrics", metricsAddr) - } - - server := &sambox.ConnectServer{ - Dialer: &sambox.AgentDialer{ - Router: &sambox.Router{Egress: egress}, - SidecarSocket: sidecarSocket, - AgentID: agentID, - }, - } - - logger.Infof("Sandbox boundary listening on %s, node at %s", sandboxSocket, sidecarSocket) - if agentID == "" { - logger.Warn("No agent bundle: this sandbox is unidentified, and mesh policy will see only the node it came through") - } else { - logger.Infof("Serving agent %s", agentID) - } - logger.Infof("Agents reach the mesh at http://%s", api.MeshEntrypointHost) - - if err := server.Serve(cmd.Context(), listener); err != nil { - return err - } - logger.Info("Sandbox boundary stopped") - return nil - }, - } - - runCmd.Flags().StringVar(&sandboxSocket, "socket", "", "Path to the sandbox-facing Unix socket to serve the boundary (HTTP CONNECT) on (required)") - runCmd.Flags().StringVar(&sidecarSocket, "sidecar-socket", "", "Path to the local sam-node API Unix socket (required)") - runCmd.Flags().StringVar(&bundlePath, "bundle", "", "Path to the agent bundle declaring the agent's identity and its egress allowance") - runCmd.Flags().StringSliceVar(&egressAllow, "egress-allow", nil, "Destinations an unidentified sandbox may reach, e.g. api.github.com or *.pypi.org; use --bundle instead where an agent has an identity") - runCmd.Flags().StringVar(&issuer, "credential-issuer", "", "Issuer whose credentials attest an agent's identity, e.g. a cluster's service-account issuer; required with --bundle") - runCmd.Flags().StringVar(&audience, "credential-audience", "", "Audience an agent's credential must be scoped to; required with --bundle") - runCmd.Flags().BoolVar(&insecure, "insecure-unverified-bundle", false, "Trust the bundle's declared identity without a credential to back it, letting whoever can write the file decide which agent this sandbox is") - runCmd.Flags().StringVar(&metricsAddr, "metrics-addr", "", "Serve unauthenticated Prometheus metrics on this address, e.g. 127.0.0.1:9600; off by default") - runCmd.Flags().StringVar(&agentIngressSocket, "agent-ingress-socket", "", "Path to the sandbox's reverse channel, served by nano-init --ingress-socket; required to reach an agent that serves the mesh, because an isolated sandbox cannot be dialled") - runCmd.Flags().StringVar(&ingressListen, "ingress-listen", "127.0.0.1:7080", "Stable address the gateway's mesh-facing ingress listens on; the node's configuration declares services with this address as their backend") - runCmd.Flags().StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)") - for _, required := range []string{"socket", "sidecar-socket"} { - if err := runCmd.MarkFlagRequired(required); err != nil { - panic(err) - } - } - - rootCmd.AddCommand(runCmd) - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - if err := rootCmd.ExecuteContext(ctx); err != nil { - logger.Fatalf("%v", err) - } -} - -// resolveAgent settles who this boundary serves. A bundle is the real answer; -// --egress-allow covers a sandbox with no identity yet, which mesh policy can -// only attribute to the node it came through. Accepting both would leave the -// egress allowance ambiguous, so it is refused rather than silently resolved. -func resolveAgent(bundlePath string, egressAllow []string, egressSet bool) (string, *sambox.EgressPolicy, error) { - if bundlePath == "" { - policy, err := sambox.NewEgressPolicy(egressAllow) - return "", policy, err - } - if egressSet { - return "", nil, fmt.Errorf("--bundle already declares the egress allowance; drop --egress-allow") - } - - bundle, err := sambox.LoadAgentBundle(bundlePath) - if err != nil { - return "", nil, err - } - return bundle.Agent.ID, bundle.EgressPolicy(), nil -} - -// verifyBundleCredential checks that the bundle is backed by a credential the -// platform issued to this workload. -// -// A bundle that is not verified is self-asserting: whoever can write the file -// picks the agent, and the identity the whole mesh reasons about rests on a -// YAML field. That is a real choice an operator may need to make, so it is -// available -- but it has to be made explicitly, in a flag that is visible in a -// process listing and a pod spec, rather than by leaving something unset. -// -// The issuer is an operator flag and never a bundle field, because the bundle -// travels with the agent: an issuer named there could be one the attacker -// controls, and their self-signed credential would verify perfectly. -func verifyBundleCredential(ctx context.Context, bundlePath, issuer, audience string, insecure bool) error { - if bundlePath == "" { - // No bundle is not a weak claim, it is no claim: the sandbox is - // unidentified and mesh policy sees only the node it came through. - return nil - } - - if insecure { - if issuer != "" || audience != "" { - return fmt.Errorf("--insecure-unverified-bundle contradicts --credential-issuer; pick one") - } - logger.Warn("--insecure-unverified-bundle: this bundle is taken at its word, so whoever can write it decides which agent this sandbox is") - return nil - } - - if issuer == "" || audience == "" { - return fmt.Errorf("--bundle needs --credential-issuer and --credential-audience so the agent it names can be checked" + - " against the credential the platform issued; pass --insecure-unverified-bundle to run without that check") - } - - verifier, err := sambox.NewWorkloadVerifier(ctx, issuer, audience) - if err != nil { - return err - } - bundle, err := sambox.LoadAgentBundle(bundlePath) - if err != nil { - return err - } - if err := verifier.Verify(ctx, bundle); err != nil { - return err - } - logger.Infof("Credential verified: %s is %s", bundle.Agent.ID, bundle.Agent.ExternalID) - return nil -} - -// resolveIngress prepares what the agent is permitted to serve. Nil means -// nothing, which is the case for a sandbox that only calls out. -func resolveIngress(bundlePath, ingressListen, agentIngressSocket string) (*sambox.IngressManager, error) { - if bundlePath == "" { - return nil, nil - } - bundle, err := sambox.LoadAgentBundle(bundlePath) - if err != nil { - return nil, err - } - if bundle.Serves == nil { - return nil, nil - } - if agentIngressSocket == "" { - // Refused rather than degraded. Without a channel into the sandbox the - // only address left is one in this process's network namespace, which - // is the pod's: the node's API and every sidecar are on that loopback, - // and the port would be the agent's to choose. - return nil, fmt.Errorf("agent %s serves a2a://%s, but --agent-ingress-socket is not set. "+ - "Point it at the path nano-init --ingress-socket serves; without it there is no way into the "+ - "sandbox, and delivering to this process's own network namespace would reach the gateway's "+ - "neighbours instead of the agent", bundle.Agent.ID, bundle.Serves.Name) - } - manager := &sambox.IngressManager{ - ListenAddr: ingressListen, - Serves: *bundle.Serves, - AgentSocket: agentIngressSocket, - } - // The routes are the bundle's contract and exist from startup; the node's - // config declares services backed by this address, and its backend probe - // fails until the agent actually binds its contracted port. - addr, err := manager.Start() - if err != nil { - return nil, fmt.Errorf("serving ingress on %s: %w", ingressListen, err) - } - logger.Infof("Agent %s serves a2a://%s; ingress at http://%s", bundle.Agent.ID, bundle.Serves.Name, addr) - return manager, nil -} diff --git a/development/examples/agent-harness/Dockerfile b/development/examples/agent-harness/Dockerfile deleted file mode 100644 index 0cdeecb2..00000000 --- a/development/examples/agent-harness/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# A sandbox image holds the harness and the init that gives it a route. -# No credentials, and no proxy configuration: the agent is unmodified and does -# not know it is confined. -FROM golang:1.27.1@sha256:3680233e3204827fbdc66088528ae6d4b3d034f51d03a99d454f6de034888244 AS init -WORKDIR /src -COPY cmd/nano-init/go.mod cmd/nano-init/go.sum ./ -RUN go mod download -COPY cmd/nano-init/ ./ -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /nano-init . - -FROM python:3.14-slim@sha256:cad9a2c871761c413caa6fdd6441c783451e740a48aaeba60ae62a8b53525ef6 - -# Nothing else is needed. nano-init carries its own TCP stack and speaks -# netlink directly, so this image has no tun2proxy, no socat and no iproute2 — -# which is the difference between a sandbox that is the agent and one that is -# the agent plus a toolbox. -COPY --from=init /nano-init /usr/local/bin/nano-init - -WORKDIR /app -COPY development/examples/agent-harness/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY development/examples/agent-harness/agent.py . - -# Building the tun needs NET_ADMIN, so this stays root. The isolation that -# matters is the sandbox around the container, not the uid inside it; a microVM -# makes the same trade with its own kernel. -ENTRYPOINT ["nano-init", "run", "/run/agent.sock", "python", "agent.py"] diff --git a/development/examples/agent-harness/README.md b/development/examples/agent-harness/README.md deleted file mode 100644 index c1de9ed4..00000000 --- a/development/examples/agent-harness/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Agent harness - -The canonical shape of an agent on SAM: about a hundred lines that hold the -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: [Sandboxed agents](https://sam-mesh.dev/docs/preview/sandboxed-agents/). - -## What it demonstrates - -- **No credentials in the sandbox.** The gateway outside authenticates this - agent to the mesh. There is no key in this directory, its environment, or the - image built from it. -- **Services addressed by name.** `mesh.sam.alt` is not in DNS and has no route - to it. The boundary resolves it and chooses a provider by policy, so the agent - never learns where anything runs. -- **An ordinary HTTP client.** The OpenAI SDK and the MCP SDK, used as they - would be anywhere. Nothing here is SAM-specific, because an agent that needed - a special client could not be moved onto the mesh without rewriting it. - -## Files - -| | | -| --- | --- | -| `agent.py` | The harness. Discovers tools, calls a model, runs the loop. | -| `bundle.yaml` | Who this agent is and what it may reach. Read by `sam-box`, outside the sandbox. | -| `Dockerfile` | The sandbox image: the harness, plus `nano-init` to give it a route. | - -The image contains no tun2proxy, no socat and no iproute2. `nano-init` carries -its own TCP stack and speaks netlink directly, so a sandbox can be the agent -and nothing else. - -## Running it - -With a `sam-node` already enrolled and serving its API on a socket: - -```bash -# A boundary for this agent. Verification is on by default; the insecure flag -# is for local experiments and says what it costs: whoever can write the -# bundle decides which agent this sandbox is. -sam-box run \ - --socket /run/sam/agent.sock \ - --sidecar-socket /run/sam/node.sock \ - --bundle ./bundle.yaml \ - --insecure-unverified-bundle \ - --metrics-addr 127.0.0.1:9600 - -# The sandbox. --network none is the assertion, not just the arrangement: -# if this worked because the container could route somewhere, it would be -# proving nothing. NET_ADMIN and /dev/net/tun are needed to build the tun that -# becomes the only route out. -docker build -t agent-harness . -docker run --rm \ - --network none \ - --cap-add NET_ADMIN \ - --device /dev/net/tun \ - -v /run/sam/agent.sock:/run/agent.sock \ - agent-harness "What tools do I have, and what can each one do?" -``` - -Notice what is *not* passed: no API key, no mesh token, no proxy variable, no -endpoint. The agent asks for `mesh.sam.alt` and the sandbox has exactly one -route, which leads to the boundary. - -## Seeing what it did - -```bash -curl -s http://127.0.0.1:9600/metrics | grep sam_box_flows_total -``` - -Every flow the agent opened, by route class and outcome, including refusals. -A refused flow never becomes a latency, so counting is the only way to see it. diff --git a/development/examples/agent-harness/agent.py b/development/examples/agent-harness/agent.py deleted file mode 100644 index 41dc5f99..00000000 --- a/development/examples/agent-harness/agent.py +++ /dev/null @@ -1,186 +0,0 @@ -"""A minimal agent harness that owns nothing but its own logic. - -This is the whole point of running an agent on SAM, in one file: there is no -model endpoint to configure, no API key to mount, no tool server to deploy -alongside it, and no network to lock down afterwards. The harness asks the mesh -for a model and for tools, and the mesh decides what this particular agent is -allowed to have. - -Three things are worth noticing while reading it. - -It holds no credentials. There is no key in this file, in its environment, or -in the sandbox it runs in. The gateway outside the sandbox knows which agent -this is and says so on every request; an agent that could assert its own -identity could borrow somebody else's. - -It addresses services by name, not by address. `mesh.sam.alt` is not in DNS and -has no route to it. The name is resolved by the boundary, which picks a -provider according to policy, so the agent never learns where anything runs and -cannot be pinned to a host that later moves. - -It is an ordinary HTTP client. Nothing here is SAM-specific: the OpenAI SDK and -the MCP SDK are used exactly as they would be anywhere. That is deliberate. If -running on the mesh required a special client, every existing agent would need -rewriting to move onto it. -""" - -import argparse -import asyncio -import json -import os -import sys - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client -from openai import AsyncOpenAI - -# The mesh's own name for itself. Not DNS, not routable, and deliberately not -# configurable: an agent that can be pointed somewhere else is one misconfigured -# environment variable away from talking to something nobody authorised. -MESH = "http://mesh.sam.alt" - -SYSTEM_PROMPT = """You are an agent running on a Sovereign Agent Mesh. - -You have tools available to you that were granted by mesh policy. Use them when -they help. If a tool is refused, that is policy, not a bug: say so and continue -without it rather than retrying. - -Answer the user's task directly and stop when it is done.""" - - -async def discover_tools(session): - """Ask the mesh what this agent is allowed to use. - - The list is not fixed at build time and is not the same for every agent: - two agents on the same node can see different tools, because the mesh - answers according to who is asking. - """ - listed = await session.list_tools() - tools = [] - for tool in listed.tools: - tools.append( - { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description or "", - # input_schema, not inputSchema: the SDK renamed it in 2.0 - # along with the transport, and the old name raises an - # AttributeError only once a tool is actually discovered. - "parameters": tool.input_schema - or {"type": "object", "properties": {}}, - }, - } - ) - return tools - - -async def call_tool(session, name, arguments): - """Run one tool call, turning a refusal into an answer the model can use. - - A denied tool must come back as text the model can reason about. Raising - here would end the run, which would turn "you may not do that" into a - crash and teach the model nothing. - """ - try: - result = await session.call_tool(name, arguments=arguments) - return "".join( - block.text for block in result.content if hasattr(block, "text") - ) or str(result.content) - except Exception as exc: # noqa: BLE001 - any failure is a result to reason about - return f"tool call failed: {exc}" - - -async def pick_model(client, requested): - """Resolve the model to ask for, preferring whatever the mesh actually offers. - - An agent that hardcodes a model name is an agent that needs configuration, - which is the thing this harness is trying not to need. The catalog is - already per-agent -- it lists what this agent's policy allows -- so asking - it is both the simplest option and the correct one. - """ - if requested: - return requested - models = await client.models.list() - if not models.data: - raise SystemExit( - "the mesh offered this agent no models; check that an inference " - "provider is registered and that policy grants access to it" - ) - return models.data[0].id - - -async def run(task, model, max_steps): - # No api_key that means anything: the gateway authenticates this agent to - # the mesh. The SDK requires the argument, so it gets a placeholder. - client = AsyncOpenAI(base_url=f"{MESH}/v1", api_key="unused") - - model = await pick_model(client, model) - print(f"mesh offered model: {model}", file=sys.stderr) - - # Streamable HTTP, which is what the mesh serves. The older SSE transport - # is answered with 400, and that arrives late enough to look like a network - # problem rather than a protocol one. - async with streamable_http_client(f"{MESH}/mcp") as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await discover_tools(session) - print(f"mesh granted {len(tools)} tools: " - f"{', '.join(t['function']['name'] for t in tools) or 'none'}", - file=sys.stderr) - - messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": task}, - ] - - for step in range(max_steps): - response = await client.chat.completions.create( - model=model, - messages=messages, - tools=tools or None, - ) - choice = response.choices[0].message - messages.append(choice.model_dump(exclude_none=True)) - - if not choice.tool_calls: - return choice.content or "" - - for call in choice.tool_calls: - arguments = json.loads(call.function.arguments or "{}") - print(f"step {step + 1}: {call.function.name}({arguments})", - file=sys.stderr) - output = await call_tool(session, call.function.name, arguments) - messages.append( - { - "role": "tool", - "tool_call_id": call.id, - "content": output, - } - ) - - return "stopped: reached the step limit without finishing" - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "task", - nargs="?", - default="Describe the tools you have and what each is for.", - help="What the agent should do", - ) - parser.add_argument( - "--model", - default=os.environ.get("SAM_MODEL", ""), - help="Model to ask the mesh for; default is whatever the mesh offers first", - ) - parser.add_argument("--max-steps", type=int, default=10) - args = parser.parse_args() - - print(asyncio.run(run(args.task, args.model, args.max_steps))) - - -if __name__ == "__main__": - main() diff --git a/development/examples/agent-harness/bundle.yaml b/development/examples/agent-harness/bundle.yaml deleted file mode 100644 index 9d2f6d90..00000000 --- a/development/examples/agent-harness/bundle.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# The agent bundle: who this agent is, and what it may reach. -# -# The bundle is read by sam-box, outside the sandbox. The agent never sees it -# and cannot change it, which is the point: an agent that could edit its own -# policy would not be constrained by one. -version: v1 - -agent: - # The mesh identity of this agent. Dot-separated and hierarchical, because - # policy matches on label boundaries: a rule for `*.prod.acme.example` admits - # this agent, and `evil-prod.acme.example` is not a match for it. - id: researcher-1.prod.acme.example - - # The platform identity this claim is checked against. sam-box verifies a - # credential from the issuer names in --credential-issuer and only accepts - # the bundle if the credential's subject is this value, so writing the file - # is not enough to become this agent. - external_id: system:serviceaccount:agents:researcher - -egress: - # Everything not listed is refused, including DNS, because the sandbox has no - # resolver and the boundary answers by name. An empty list means the agent - # reaches the mesh and nothing else, which is the right default for an agent - # whose work is entirely tools and inference. - allow: - - api.github.com - - "*.githubusercontent.com" diff --git a/development/examples/agent-harness/requirements.txt b/development/examples/agent-harness/requirements.txt deleted file mode 100644 index 3f6b2269..00000000 --- a/development/examples/agent-harness/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# The harness needs a model client and a tool client. It does not need a SAM -# client, because there is no such thing: the mesh is reached with ordinary -# HTTP to an ordinary name. -# -# There is no proxy support here on purpose. The sandbox routes the agent's -# traffic to the boundary through tun0, so the agent neither configures a proxy -# nor knows there is one. -openai>=3.18.0,<4 -# Pinned to a major version: 1.x spells the transport streamablehttp_client and -# 2.x spells it streamable_http_client, so an unpinned floor silently changes -# the API underneath a sandbox image. -mcp>=2.2.0,<3 diff --git a/hack/gen-sdk-datalog/main.go b/hack/gen-sdk-datalog/main.go index cd34680c..dd3ca1fb 100644 --- a/hack/gen-sdk-datalog/main.go +++ b/hack/gen-sdk-datalog/main.go @@ -33,7 +33,6 @@ type artifact struct { api.DatalogSources FactService string `json:"fact_service"` FactConnectionPeer string `json:"fact_connection_peer_id"` - FactAgent string `json:"fact_agent"` FactMethod string `json:"fact_method"` FactPath string `json:"fact_path"` FactTime string `json:"fact_time"` @@ -55,7 +54,6 @@ func main() { DatalogSources: api.BaselineSources, FactService: api.FactService, FactConnectionPeer: api.FactConnectionPeerID, - FactAgent: api.FactAgent, FactMethod: api.FactMethod, FactPath: api.FactPath, FactTime: api.FactTime, diff --git a/hack/lint.sh b/hack/lint.sh index 9636a05c..ee40fcd9 100755 --- a/hack/lint.sh +++ b/hack/lint.sh @@ -23,9 +23,6 @@ REPO_ROOT=$(dirname "${BASH_SOURCE[0]}")/.. cd $REPO_ROOT docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:v2.14.0 golangci-lint run -v -# nano-init is a separate module, so ./... above does not reach it. -docker run --rm -v $(pwd):/app -w /app/cmd/nano-init golangci/golangci-lint:v2.14.0 golangci-lint run -v - # golangci-lint has no deadcode linter (removed upstream in v1.49) and its # replacement, "unused", ignores exported identifiers. This catches exported # code that is unreachable from every binary and test. diff --git a/internal/console/public/index.html b/internal/console/public/index.html index 5a70c540..aecb085a 100644 --- a/internal/console/public/index.html +++ b/internal/console/public/index.html @@ -290,7 +290,6 @@

Generate Bootstrap Token

diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index 338227f6..a5ddd86b 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -2775,8 +2775,8 @@ func (s *Server) HandleUserBootstrapTokens(w http.ResponseWriter, r *http.Reques req.Role = api.RoleNode } - if user.Role != "admin" && req.Role != api.RoleNode && req.Role != api.RoleSamBox { - http.Error(w, "Forbidden: Standard users can only generate tokens for node or box roles", http.StatusForbidden) + if user.Role != "admin" && req.Role != api.RoleNode { + http.Error(w, "Forbidden: Standard users can only generate tokens for node role", http.StatusForbidden) return } if user.Role != "admin" && req.AutonomousRecovery { @@ -3110,11 +3110,6 @@ func validatePolicyConfig(req *api.PolicyConfig) error { return fmt.Errorf("invalid allowed_target %q in role %s: %w", target, r.Name, err) } } - for _, agent := range r.AllowedAgents { - if err := api.ValidateAgentPattern(agent); err != nil { - return fmt.Errorf("invalid allowed_agent %q in role %s: %w", agent, r.Name, err) - } - } for _, label := range r.AllowedLabels { if err := api.ValidateLabelPattern(label); err != nil { return fmt.Errorf("in role %s: %w", r.Name, err) @@ -3143,7 +3138,6 @@ func validatePolicyConfig(req *api.PolicyConfig) error { // which roles are mutually exclusive for a given identity. factBudget += len(api.BuildServiceDatalogFacts(r.AllowedServices)) factBudget += len(api.BuildTargetDatalogFacts(r.AllowedTargets)) - factBudget += len(api.BuildAgentDatalogFacts(r.AllowedAgents)) factBudget += len(r.CustomDatalog) for _, g := range r.Http { factBudget += len(api.BuildHTTPGrantFacts(g)) diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index d029b2cb..afafe6fa 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -2392,8 +2392,8 @@ func TestResolveRolesAndRoleImpersonationProtection(t *testing.T) { Members: []string{"group:routers", "idp_role:oidc-router-role"}, }, { - Role: api.RoleSamBox, - Members: []string{"user:sambox-admin-sub"}, + Role: api.RoleNode, + Members: []string{"user:node-admin-sub"}, }, { // A binding on the mesh role fact itself: must never resolve from a @@ -2406,11 +2406,11 @@ func TestResolveRolesAndRoleImpersonationProtection(t *testing.T) { t.Run("OIDC claims role is not blindly trusted without explicit binding", func(t *testing.T) { claims := jwt.MapClaims{ "sub": "attacker-sub", - "roles": []string{api.RoleRouter, api.RoleSamBox, "unbound-role"}, + "roles": []string{api.RoleRouter, api.RoleNode, "unbound-role"}, } roles := resolveRoles("peer-123", claims, bindings) for _, r := range roles { - if r == api.RoleRouter || r == api.RoleSamBox { + if r == api.RoleRouter || r == api.RoleNode { t.Errorf("Security flaw: resolveRoles granted capability role %q from raw OIDC claims without explicit binding", r) } } @@ -2449,17 +2449,17 @@ func TestResolveRolesAndRoleImpersonationProtection(t *testing.T) { t.Run("User sub grants bound capability role", func(t *testing.T) { claims := jwt.MapClaims{ - "sub": "sambox-admin-sub", + "sub": "node-admin-sub", } roles := resolveRoles("peer-789", claims, bindings) - hasSamBox := false + hasNode := false for _, r := range roles { - if r == api.RoleSamBox { - hasSamBox = true + if r == api.RoleNode { + hasNode = true } } - if !hasSamBox { - t.Errorf("Expected role %q to be granted via user sub binding", api.RoleSamBox) + if !hasNode { + t.Errorf("Expected role %q to be granted via user sub binding", api.RoleNode) } }) } @@ -2634,7 +2634,7 @@ func TestAuthDenialPaths(t *testing.T) { reqData := newEnrollBody(unauthorizedJWT) var req api.EnrollRequest _ = proto.Unmarshal(reqData, &req) - req.RequestedRole = api.RoleSamBox // not granted to "group:outsiders" + req.RequestedRole = "custom-role" // not granted to "group:outsiders" reqData, _ = proto.Marshal(&req) resp, err := client.Post(baseURL+"/register", "application/x-protobuf", bytes.NewReader(reqData)) diff --git a/internal/identity/attenuation_test.go b/internal/identity/attenuation_test.go index 4bbbe97d..b49efc86 100644 --- a/internal/identity/attenuation_test.go +++ b/internal/identity/attenuation_test.go @@ -62,7 +62,7 @@ func TestAttenuationBlockFactsAreInvisibleToTheAuthorizer(t *testing.T) { block := token.CreateBlock() if err := block.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactAgent, + Name: "custom_claim", IDs: []biscuit.Term{biscuit.String("reviewer-7.prod.acme.example")}, }}); err != nil { t.Fatalf("AddFact: %v", err) @@ -92,7 +92,7 @@ func TestAttenuationBlockFactsAreInvisibleToTheAuthorizer(t *testing.T) { } // ...but the appended block's fact is not. - if got := queryOne(t, authorizer, api.FactAgent); got != "" { + if got := queryOne(t, authorizer, "custom_claim"); got != "" { t.Errorf("appended block fact is visible to the authorizer as %q."+ " If this now passes, biscuit-go changed its scoping and delegation"+ " by attenuation is worth revisiting", got) diff --git a/internal/identity/biscuit.go b/internal/identity/biscuit.go index 433f4226..f560a2d9 100644 --- a/internal/identity/biscuit.go +++ b/internal/identity/biscuit.go @@ -238,10 +238,6 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri // stay siloed per role, and merging keeps fact counts flat regardless of how many roles match. var allServices []string var allTargets []string - // Agent namespaces this holder can act for. No grant means no agent claim - // is accepted, which is what stops an unconfigured mesh from letting any - // peer name any agent. - var allAgents []string // Narrowed grants (PolicyRole.http) are minted per entry: they are keyed // by the entry they narrow, so there is nothing to merge across roles. var allHTTP []*api.HTTPGrant @@ -272,7 +268,6 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri allServices = append(allServices, plainServices...) allHTTP = append(allHTTP, narrowed...) allTargets = append(allTargets, pr.AllowedTargets...) - allAgents = append(allAgents, pr.AllowedAgents...) for _, customEntry := range pr.CustomDatalog { trimmed := strings.TrimRight(strings.TrimSpace(customEntry), ";") @@ -314,11 +309,6 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri errs = append(errs, fmt.Errorf("failed to add target fact: %w", err)) } } - for _, fact := range api.BuildAgentDatalogFacts(allAgents) { - if err := addFact(fact); err != nil { - errs = append(errs, fmt.Errorf("failed to add agent namespace fact: %w", err)) - } - } // No policy means no grants. A mesh with no roles defined used to mint // every non-router an unrestricted token; a fresh control plane, or one diff --git a/internal/node/agent.go b/internal/node/agent.go deleted file mode 100644 index 7cf2b757..00000000 --- a/internal/node/agent.go +++ /dev/null @@ -1,107 +0,0 @@ -// 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. - -package node - -import ( - "context" - "net/http" - - "github.com/google/sam/api" -) - -// An agent has no key and no enrolment: it is a sandbox, and giving every -// sandbox a mesh identity is the cost this design exists to avoid. So the node -// it runs behind speaks for it, naming the agent alongside its own token. -// -// What this covers: attribution and policy on both datapaths. A peer can -// authorize and audit "agent reviewer-7 called me", not merely "some node did", -// and it can do so with the existing vocabulary because the claim is injected -// as an ordinary agent() fact. HTTP requests carry it in api.HeaderSamAgent; -// libp2p streams carry it in the AuthFrame, bound to the MCP session rather -// than the request, since the SDK gives a tool handler the session's context. -// -// What it does not cover, and cannot: -// -// - 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. This is not a weakness of carrying the claim -// beside the token: an appended Biscuit block would be exactly as forgeable -// by the same party, and is invisible to the authorizer besides (see -// internal/identity's TestAttenuationBlockFactsAreInvisibleToTheAuthorizer). -// Only a block signed by the agent's own key would be proof, which needs -// third-party blocks that biscuit-go does not implement. -// - Anything an agent does that never leaves its node. -// -// One consequence worth knowing before writing such a policy: a node's own -// housekeeping carries no agent, because no agent asked for it. A provider -// whose policy demands an agent 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. Policies that mean to gate agent -// traffic should say so, rather than demanding an agent unconditionally. - -// agentFromLocalGateway returns the agent a local gateway is speaking for. -// -// Only the node's Unix socket can name an agent: its permissions are the -// credential, so a caller that reached it is the gateway that admitted the -// sandbox. A claim arriving over TCP is from something that is not the gateway -// and is dropped. -// -// An invalid identifier is dropped rather than rejected. The request continues -// unattributed, which is the same position the mesh was in before agents -// existed, and refusing outright would turn a malformed bundle into an outage. -func agentFromLocalGateway(r *http.Request) string { - if !fromLocalSocket(r) { - return "" - } - agentID := agentClaim(r.Header.Get(api.HeaderSamAgent)) - recordAgentSeen(agentID) - return agentID -} - -// agentClaim validates an agent identifier arriving from elsewhere, returning -// "" for anything malformed so a bad claim is worth no more than no claim. -func agentClaim(agentID string) string { - if agentID == "" { - return "" - } - if err := api.ValidateAgentID(agentID); err != nil { - logger.Warnf("[Auth] Ignoring malformed agent claim %q: %v", agentID, err) - return "" - } - return agentID -} - -type agentContextKey struct{} - -// contextWithAgent carries the agent an MCP session belongs to down to the code -// that opens streams on its behalf. -// -// The MCP SDK hands a tool handler the session's context, not the HTTP -// request's, so the agent is bound once when the session's server is built -// (NewMCPHandler) rather than read per request. That matches how sandboxes -// work: one gateway serves one agent, so one session belongs to one agent for -// its whole life. -func contextWithAgent(ctx context.Context, agentID string) context.Context { - if agentID == "" { - return ctx - } - return context.WithValue(ctx, agentContextKey{}, agentID) -} - -// agentFromContext returns the agent a request is being made for, if any. -func agentFromContext(ctx context.Context) string { - agentID, _ := ctx.Value(agentContextKey{}).(string) - return agentID -} diff --git a/internal/node/agent_metrics.go b/internal/node/agent_metrics.go deleted file mode 100644 index 62e44376..00000000 --- a/internal/node/agent_metrics.go +++ /dev/null @@ -1,83 +0,0 @@ -// 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. - -package node - -import ( - "sync" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -// How many agents a node is actually serving is the one number a mesh of -// agents cannot be described without, and until now nothing counted it. A node -// knew how many peers it had, which is a statement about hosts, not about the -// principals running on them. -// -// It is a count and not a label. Putting the agent identifier on a metric -// would be the obvious way to answer the same question, and it would put a -// thousand label values on a thousand series the first time anyone ran this at -// the scale it is for. - -// maxTrackedAgents bounds the set behind the gauge. The identifiers come from -// the local gateway, which is trusted, so this is not defending against an -// attacker so much as against a bug: a gateway generating a fresh identity per -// request would otherwise grow this map until the node died, and a memory leak -// in the thing measuring the experiment is a bad way to end one. -const maxTrackedAgents = 100_000 - -var ( - agentsSeen = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "sam_node_agents_seen", - Help: "Distinct agents this node has served for a local gateway", - }, - ) - - agentsUntrackedTotal = promauto.NewCounter( - prometheus.CounterOpts{ - Name: "sam_node_agents_untracked_total", - Help: "Agent claims not counted because the tracking limit was reached", - }, - ) -) - -var seenAgents = struct { - sync.Mutex - ids map[string]struct{} -}{ids: make(map[string]struct{})} - -// recordAgentSeen counts an agent the first time this node serves it. -func recordAgentSeen(agentID string) { - if agentID == "" { - return - } - - seenAgents.Lock() - defer seenAgents.Unlock() - - if _, known := seenAgents.ids[agentID]; known { - return - } - if len(seenAgents.ids) >= maxTrackedAgents { - // Counted rather than silently dropped: a gauge that stops moving - // looks like a mesh that stopped growing. - agentsUntrackedTotal.Inc() - return - } - - seenAgents.ids[agentID] = struct{}{} - agentsSeen.Set(float64(len(seenAgents.ids))) -} diff --git a/internal/node/agent_metrics_test.go b/internal/node/agent_metrics_test.go deleted file mode 100644 index 5dea4705..00000000 --- a/internal/node/agent_metrics_test.go +++ /dev/null @@ -1,104 +0,0 @@ -// 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. - -package node - -import ( - "strconv" - "testing" - - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" -) - -func resetSeenAgents() { - seenAgents.Lock() - defer seenAgents.Unlock() - seenAgents.ids = make(map[string]struct{}) - agentsSeen.Set(0) -} - -func TestAgentsSeenCountsEachAgentOnce(t *testing.T) { - // The gauge answers "how many agents is this node serving". Counting a - // busy agent repeatedly would answer "how many requests arrived", which - // is a different question that other metrics already answer. - resetSeenAgents() - - for range 5 { - recordAgentSeen("reviewer-7.prod.acme.example") - } - recordAgentSeen("planner-2.prod.acme.example") - - if got := gaugeValue(t, agentsSeen); got != 2 { - t.Errorf("agents seen = %v, want 2", got) - } -} - -func TestAgentsSeenIgnoresAnAbsentClaim(t *testing.T) { - // Unattributed requests are normal: a node's own housekeeping carries no - // agent. Counting the empty string would invent an agent that never ran. - resetSeenAgents() - - recordAgentSeen("") - - if got := gaugeValue(t, agentsSeen); got != 0 { - t.Errorf("agents seen = %v, want 0", got) - } -} - -func TestAgentsSeenStopsGrowingAtTheLimit(t *testing.T) { - // A gateway minting a fresh identity per request would otherwise grow this - // map until the node died, which is a poor way for an experiment to end. - resetSeenAgents() - - seenAgents.Lock() - for i := range maxTrackedAgents { - seenAgents.ids[strconv.Itoa(i)] = struct{}{} - } - seenAgents.Unlock() - - before := counterValue(t, agentsUntrackedTotal) - recordAgentSeen("one-too-many.prod.acme.example") - - if got := counterValue(t, agentsUntrackedTotal); got != before+1 { - t.Errorf("untracked total = %v, want %v: the limit was hit silently", got, before+1) - } - - seenAgents.Lock() - overLimit := len(seenAgents.ids) > maxTrackedAgents - seenAgents.Unlock() - if overLimit { - t.Error("the tracking set grew past its limit") - } - - resetSeenAgents() -} - -func gaugeValue(t *testing.T, g prometheus.Gauge) float64 { - t.Helper() - var m dto.Metric - if err := g.Write(&m); err != nil { - t.Fatalf("read gauge: %v", err) - } - return m.GetGauge().GetValue() -} - -func counterValue(t *testing.T, c prometheus.Counter) float64 { - t.Helper() - var m dto.Metric - if err := c.Write(&m); err != nil { - t.Fatalf("read counter: %v", err) - } - return m.GetCounter().GetValue() -} diff --git a/internal/node/agent_namespace_test.go b/internal/node/agent_namespace_test.go deleted file mode 100644 index f899dbbe..00000000 --- a/internal/node/agent_namespace_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// 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. - -package node - -import ( - "crypto/ed25519" - "testing" - "time" - - "github.com/biscuit-auth/biscuit-go/v2" - "github.com/google/sam/api" - "github.com/libp2p/go-libp2p/core/peer" -) - -// An agent claim travels beside the token as the calling node's word, so the -// only thing limiting it is the namespace grant in that node's own token. -// Without the limit, any authenticated peer could name any agent and pick up -// whatever role an agent: binding gives it, choosing its own principal. These -// tests cover the limit at the point it is applied. -func TestAuthorizeBoundsTheAgentClaimToTheGrantedNamespace(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatal(err) - } - callerPeer := peer.ID("caller-peer") - - // grants is what the caller's own token attests it may speak for. - mint := func(t *testing.T, grants []string) []byte { - t.Helper() - builder := biscuit.NewBuilder(priv) - facts := []biscuit.Fact{ - api.MarkerFact(api.FactTargetUnrestricted), - {Predicate: biscuit.Predicate{Name: api.FactNode, IDs: []biscuit.Term{biscuit.String(callerPeer.String())}}}, - {Predicate: biscuit.Predicate{Name: api.FactClientPeerID, IDs: []biscuit.Term{biscuit.String(callerPeer.String())}}}, - {Predicate: biscuit.Predicate{Name: api.FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String(api.SystemNamespace), biscuit.String("/test/proto")}}}, - {Predicate: biscuit.Predicate{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Date(time.Now().Add(time.Hour))}}}, - } - facts = append(facts, api.BuildAgentDatalogFacts(grants)...) - for _, f := range facts { - if err := builder.AddAuthorityFact(f); err != nil { - t.Fatal(err) - } - } - b, err := builder.Build() - if err != nil { - t.Fatal(err) - } - data, err := b.Serialize() - if err != nil { - t.Fatal(err) - } - return data - } - - node := &SamNode{ - trustedKeys: []TrustedKey{{Key: pub, ReceivedAt: time.Now()}}, - BiscuitTimeout: 500 * time.Millisecond, - } - - authorize := func(t *testing.T, grants []string, agent string) error { - t.Helper() - return node.Authorize(mint(t, grants), RequestContext{ - PeerID: callerPeer, - Protocol: "/test/proto", - Agent: agent, - }, pub) - } - - tests := []struct { - name string - grants []string - agent string - wantErr bool - }{ - { - name: "claim inside the granted suffix", - grants: []string{"*.prod.acme.example"}, - agent: "reviewer-7.prod.acme.example", - }, - { - name: "claim outside the granted suffix", - grants: []string{"*.prod.acme.example"}, - agent: "auditor-1.staging.acme.example", - wantErr: true, - }, - { - // The reason agent ids are dot-anchored: a suffix grant keeps its - // leading dot, so a lookalike authority is a different namespace. - name: "lookalike authority does not satisfy a suffix grant", - grants: []string{"*.prod.acme.example"}, - agent: "intruder.evil-prod.acme.example", - wantErr: true, - }, - { - name: "no grant at all", - grants: nil, - agent: "reviewer-7.prod.acme.example", - wantErr: true, - }, - { - name: "exact grant matches exactly", - grants: []string{"reviewer-7.prod.acme.example"}, - agent: "reviewer-7.prod.acme.example", - }, - { - name: "exact grant does not cover a sibling", - grants: []string{"reviewer-7.prod.acme.example"}, - agent: "reviewer-8.prod.acme.example", - wantErr: true, - }, - { - name: "wildcard grant covers anything", - grants: []string{"*"}, - agent: "anyone.anywhere.example", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := authorize(t, tt.grants, tt.agent) - if tt.wantErr && err == nil { - t.Errorf("peer speaking for %q with grants %v was allowed; the claim is unbounded", tt.agent, tt.grants) - } - if !tt.wantErr && err != nil { - t.Errorf("peer speaking for %q with grants %v was refused: %v", tt.agent, tt.grants, err) - } - }) - } - - // A node's own housekeeping acts for no agent. Requiring a grant - // unconditionally would refuse it, and its models would stop appearing in - // peers' catalogues. - t.Run("no agent claim needs no grant", func(t *testing.T) { - if err := authorize(t, nil, ""); err != nil { - t.Errorf("unattributed request refused: %v", err) - } - }) -} diff --git a/internal/node/agent_test.go b/internal/node/agent_test.go deleted file mode 100644 index 8cf3ac1d..00000000 --- a/internal/node/agent_test.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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. - -package node - -import ( - "net" - "net/http" - "net/http/httptest" - "testing" - - "github.com/google/sam/api" -) - -// localSocketConn looks like a connection accepted on the node's Unix socket, -// which is what markLocalSocketConn keys off. -type localSocketConn struct{ net.Conn } - -func (localSocketConn) LocalAddr() net.Addr { - return &net.UnixAddr{Name: "/run/sam/node.sock", Net: "unix"} -} - -func requestWithAgent(agentID string, overSocket bool) *http.Request { - req := httptest.NewRequest(http.MethodGet, "/sam/12D3KooWpeer/mcp/svc", nil) - if agentID != "" { - req.Header.Set(api.HeaderSamAgent, agentID) - } - if overSocket { - req = req.WithContext(markLocalSocketConn(req.Context(), localSocketConn{})) - } - return req -} - -func TestAgentFromLocalGateway(t *testing.T) { - tests := []struct { - name string - agentID string - overSocket bool - want string - }{ - { - name: "named by the local gateway", - agentID: "reviewer-7.prod.acme.example", - overSocket: true, - want: "reviewer-7.prod.acme.example", - }, - { - // The socket is what identifies the gateway, so a claim from - // anywhere else is from something that is not the gateway. Honouring - // it would let any local process with the API token speak for any - // agent. - name: "claimed over TCP", - agentID: "privileged.prod.acme.example", - overSocket: false, - want: "", - }, - {name: "no claim", overSocket: true, want: ""}, - {name: "malformed identifier", agentID: "not a valid id", overSocket: true, want: ""}, - {name: "a pattern rather than an identity", agentID: "*.prod.acme.example", overSocket: true, want: ""}, - {name: "an identifier with no authority", agentID: "reviewer", overSocket: true, want: ""}, - {name: "uppercase, which DNS cannot distinguish", agentID: "Reviewer.acme.example", overSocket: true, want: ""}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := agentFromLocalGateway(requestWithAgent(tc.agentID, tc.overSocket)); got != tc.want { - t.Errorf("agentFromLocalGateway = %q, want %q", got, tc.want) - } - }) - } -} - -// TestAgentClaimDropsRatherThanRejects: an unattributed request is the position -// the mesh was in before agents existed, whereas refusing would turn one -// malformed bundle into an outage for that sandbox. -func TestAgentClaimDropsRatherThanRejects(t *testing.T) { - for _, claim := range []string{"", "not a valid id", "*.acme.example", "UPPER.acme.example"} { - if got := agentClaim(claim); got != "" { - t.Errorf("agentClaim(%q) = %q, want it dropped", claim, got) - } - } - if got := agentClaim("reviewer.acme.example"); got != "reviewer.acme.example" { - t.Errorf("agentClaim dropped a valid identifier: %q", got) - } -} diff --git a/internal/node/egress.go b/internal/node/egress.go index 6dedc4c0..9046af5a 100644 --- a/internal/node/egress.go +++ b/internal/node/egress.go @@ -294,9 +294,8 @@ func (n *SamNode) applyPendingEgress(ctx context.Context) { // handleLocalEgress serves /egress/{host}/{path} on the local API: a client // of this node asks for a destination this node serves. The caller is this // node, so its own credential is evaluated, with the request's method and -// path and the destination's host and port, and with the agent the client -// names, as on the mesh datapath. The client's Authorization was for the -// node and does not travel further. +// path and the destination's host and port, as on the mesh datapath. The +// client's Authorization was for the node and does not travel further. func handleLocalEgress(node *SamNode, w http.ResponseWriter, r *http.Request) { if hasDotSegment(r.URL.Path) { http.Error(w, "Invalid path", http.StatusBadRequest) @@ -324,7 +323,6 @@ func handleLocalEgress(node *SamNode, w http.ResponseWriter, r *http.Request) { PeerID: node.Host.ID(), Protocol: "local-api", Target: target, - Agent: agentClaim(r.Header.Get(api.HeaderSamAgent)), HTTP: &HTTPRequestFacts{Method: r.Method, Path: "/" + upstreamPath}, Egress: egressFactsFor(svc), Local: true, @@ -336,7 +334,6 @@ func handleLocalEgress(node *SamNode, w http.ResponseWriter, r *http.Request) { } recordEgressDecision(host, egressOutcomeAllow) r.Header.Del(api.HeaderSamBiscuit) - r.Header.Del(api.HeaderSamAgent) r.Header.Set(api.HeaderPeerID, node.Host.ID().String()) r.URL.Path = "/" + upstreamPath r.URL.RawPath = "" diff --git a/internal/node/egress_route_test.go b/internal/node/egress_route_test.go index dea84e1d..5531f886 100644 --- a/internal/node/egress_route_test.go +++ b/internal/node/egress_route_test.go @@ -48,10 +48,9 @@ func TestLocalEgressRoute(t *testing.T) { // The node's credential, as the control plane would mint it for a role // selected to serve api.github.com: the serving grant narrowed to GET - // under /repos/acme/, and an agent namespace it may speak for. + // under /repos/acme/. narrowed := api.BuildHTTPGrantFacts(&api.HTTPGrant{Service: "egress://api.github.com", Methods: []string{"GET"}, Paths: []string{"/repos/acme/*"}}) facts := append(narrowed, - biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactGrantedAgentSuffix, IDs: []biscuit.Term{biscuit.String(".acme.example")}}}, biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String("egress"), biscuit.String("open.example")}}}, ) token, pub := mintFor(t, node.Host.ID(), facts...) @@ -124,8 +123,6 @@ func TestLocalEgressRoute(t *testing.T) { {"method outside the narrowed grant", "POST", "/egress/api.github.com/repos/acme/dubbing/pulls", nil, http.StatusForbidden}, {"path outside the narrowed grant", "GET", "/egress/api.github.com/user", nil, http.StatusForbidden}, {"local attenuation on path", "GET", "/egress/api.github.com/repos/acme/vault/keys", nil, http.StatusForbidden}, - {"an agent inside the granted namespace", "GET", "/egress/api.github.com/repos/acme/x", map[string]string{api.HeaderSamAgent: "reviewer.acme.example"}, http.StatusNoContent}, - {"an agent outside it", "GET", "/egress/api.github.com/repos/acme/x", map[string]string{api.HeaderSamAgent: "intruder.evil.example"}, http.StatusForbidden}, {"a destination with a plain grant takes any method", "DELETE", "/egress/open.example/anything", nil, http.StatusNoContent}, {"a destination not assigned to this node", "GET", "/egress/other.example/x", nil, http.StatusNotFound}, {"a mesh name is not an egress destination", "GET", "/egress/tools.mcp.sam.alt/x", nil, http.StatusNotFound}, @@ -157,8 +154,8 @@ func TestLocalEgressRoute(t *testing.T) { // destination and outcome, so an operator can alert on denials and on // requests for destinations nobody assigned. for k, want := range map[[2]string]float64{ - {"api.github.com", egressOutcomeAllow}: 2, - {"api.github.com", egressOutcomeDeny}: 4, + {"api.github.com", egressOutcomeAllow}: 1, + {"api.github.com", egressOutcomeDeny}: 3, {"open.example", egressOutcomeAllow}: 1, {"other.example", egressOutcomeNotAssigned}: 1, } { diff --git a/internal/node/egress_test.go b/internal/node/egress_test.go index b46a1975..889dbeca 100644 --- a/internal/node/egress_test.go +++ b/internal/node/egress_test.go @@ -28,6 +28,8 @@ import ( "github.com/biscuit-auth/biscuit-go/v2" "github.com/google/sam/api" "github.com/libp2p/go-libp2p/core/peer" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" ) // mintFor builds a token bound to peerID carrying facts, as the control plane @@ -198,7 +200,6 @@ func TestEgressServiceProxiesWithTheNodesCredential(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/repos/acme/x?state=open", nil) req.Header.Set("Authorization", "Bearer the-callers-token") req.Header.Set("Cookie", "session=the-callers-session") - req.Header.Set(api.HeaderSamAgent, "reviewer.acme.example") req.Header.Set(api.HeaderPeerID, "12D3KooWCaller") req.Header.Set("X-Forwarded-For", "10.0.0.1") req.Header.Set("Accept", "application/json") @@ -210,7 +211,7 @@ func TestEgressServiceProxiesWithTheNodesCredential(t *testing.T) { if got.Header.Get("Authorization") != "Bearer ghp_token" { t.Errorf("upstream Authorization = %q, want the node's credential", got.Header.Get("Authorization")) } - for _, h := range []string{"Cookie", api.HeaderSamAgent, api.HeaderPeerID, "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { + for _, h := range []string{"Cookie", api.HeaderPeerID, "X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { if got.Header.Get(h) != "" { t.Errorf("upstream saw %s=%q", h, got.Header.Get(h)) } @@ -364,3 +365,12 @@ func TestNodeConfigRefusesEgressServices(t *testing.T) { t.Fatalf("NewServiceFromRequest accepted an egress service: %v", err) } } + +func counterValue(t *testing.T, c prometheus.Counter) float64 { + t.Helper() + var m dto.Metric + if err := c.Write(&m); err != nil { + t.Fatalf("read counter: %v", err) + } + return m.GetCounter().GetValue() +} diff --git a/internal/node/mcp.go b/internal/node/mcp.go index 494935a9..ba52d177 100644 --- a/internal/node/mcp.go +++ b/internal/node/mcp.go @@ -21,7 +21,6 @@ import ( "fmt" "net/http" "strings" - "sync" "time" "github.com/google/sam/api" @@ -174,13 +173,10 @@ func NewUnauthenticatedMCPHandler(controlPlaneURL string) http.Handler { // NewMCPHandler creates a new HTTP handler for the MCP server using the official SDK. func NewMCPHandler(node *SamNode) http.Handler { - servers := &agentMCPServers{node: node} + mcpServer := NewMCPServer(node) - // Per agent, not per node: the SDK gives a tool handler the session's - // context rather than the request's, so the only place to bind who the - // request belongs to is where its server is chosen. streamableHandler := mcp.NewStreamableHTTPHandler(func(request *http.Request) *mcp.Server { - return servers.forAgent(agentFromLocalGateway(request)) + return mcpServer }, streamableOptions) mux := http.NewServeMux() @@ -195,40 +191,6 @@ func NewMCPHandler(node *SamNode) http.Handler { return wrappedHandler } -// agentMCPServers hands out one server per agent, built on first use. A node -// serves a handful of sandboxes, so this stays small; without it every request -// would rebuild the whole tool set. -type agentMCPServers struct { - node *SamNode - - mu sync.Mutex - servers map[string]*mcp.Server -} - -func (s *agentMCPServers) forAgent(agentID string) *mcp.Server { - s.mu.Lock() - defer s.mu.Unlock() - - if server, ok := s.servers[agentID]; ok { - return server - } - - server := NewMCPServer(s.node) - if agentID != "" { - server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { - return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { - return next(contextWithAgent(ctx, agentID), method, req) - } - }) - } - - if s.servers == nil { - s.servers = make(map[string]*mcp.Server) - } - s.servers[agentID] = server - return server -} - // CallMCPTool opens a stream to a remote peer, performs the handshake, and calls a tool. // requiredLabels, when non-empty, fail-closed verifies the peer's control-plane-attested // labels (see checkPeerLabels) before the tool is invoked; nil means no caller @@ -328,7 +290,6 @@ func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, tar authFrame := api.AuthFrame{ Biscuit: biscuitBytes, TargetService: targetService, - Agent: agentFromContext(ctx), } authBytes, _ := proto.Marshal(&authFrame) diff --git a/internal/node/middleware.go b/internal/node/middleware.go index 2d627df5..ddc66c53 100644 --- a/internal/node/middleware.go +++ b/internal/node/middleware.go @@ -38,19 +38,6 @@ type RequestContext struct { Protocol string Target string - // Agent is the principal the calling node says the request is for, and it - // is exactly that: the calling node's word. It arrives beside the token - // rather than inside it, because Biscuit deliberately hides an appended - // block's facts from the authorizer (see internal/identity's - // TestAttenuationBlockFactsAreInvisibleToTheAuthorizer). Nothing is lost by - // that: whoever can append a block can append any block, so a claim in a - // block would be worth no more than a claim in a header on the same - // authenticated connection. - // - // So it is attribution, not proof. Policy that cares should also constrain - // which peers may speak for which agent namespaces. - Agent string - // HTTP is set when the node handles the request as HTTP: the method as // received and the path as the backend sees it. Injected as method() and // path() facts, taken from the wire and never from the caller's token. A @@ -169,7 +156,6 @@ func (n *SamNode) WithBiscuitAuth(next func(network.Stream, RequestContext)) net User: "", // Not used in Authorize Protocol: string(ts.Protocol()), Target: authFrame.TargetService, - Agent: agentClaim(authFrame.GetAgent()), } writer := msgio.NewVarintWriter(ts) @@ -284,28 +270,6 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. }, }) - // The calling node's claim about which agent it speaks for. Injected here - // rather than trusted from the token, so it is visible to policy while - // staying plainly what it is: an assertion by the peer at the other end. - // - // The claim is limited to the agent namespaces the caller's own token - // grants. Without that limit any authenticated peer could name any agent - // and pick up whatever role an agent: binding gives it. The check runs only - // when a claim is present, because a node's own housekeeping acts for no - // agent and would otherwise be refused. - if req.Agent != "" { - authorizer.AddFact(biscuit.Fact{ - Predicate: biscuit.Predicate{ - Name: api.FactAgent, - IDs: []biscuit.Term{biscuit.String(req.Agent)}, - }, - }) - for _, r := range api.BaselineAgentRules { - authorizer.AddRule(r) - } - authorizer.AddCheck(api.BaselineAgentCheck) - } - // Enforce client_peer_id matches connection_peer_id authorizer.AddCheck(api.BaselineReplayCheck) @@ -432,9 +396,6 @@ func (req RequestContext) auditFields() []any { "target", req.Target, "protocol", req.Protocol, } - if req.Agent != "" { - fields = append(fields, "agent", req.Agent) - } if req.HTTP != nil { fields = append(fields, "method", req.HTTP.Method, "path", req.HTTP.Path) } diff --git a/internal/node/node.go b/internal/node/node.go index c8d8398b..daaaf6a5 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -2172,7 +2172,6 @@ func (n *SamNode) StartIngressServer(ctx context.Context) error { User: "", // Extracted implicitly if needed, or left empty Protocol: "/libp2p-http", Target: target, - Agent: agentClaim(r.Header.Get(api.HeaderSamAgent)), // The path policy sees is the one the backend will see, decided // here so it can never be the routing prefix. HTTP: &HTTPRequestFacts{Method: r.Method, Path: "/" + upstreamPath}, @@ -2198,9 +2197,6 @@ func (n *SamNode) StartIngressServer(ctx context.Context) error { // Strip the biscuit header so it doesn't leak to the backend service r.Header.Del(api.HeaderSamBiscuit) - // The agent is for policy, not for the backend, which has no way to - // judge it. - r.Header.Del(api.HeaderSamAgent) // Set, not Add: an inbound value is a spoof attempt, only the // transport-verified identity may reach the backend. r.Header.Del(api.HeaderSamNoTrailingSlash) diff --git a/internal/node/sidecar.go b/internal/node/sidecar.go index 0cf6338b..0c7972fa 100644 --- a/internal/node/sidecar.go +++ b/internal/node/sidecar.go @@ -793,16 +793,6 @@ func createEgressProxy(node *SamNode) http.Handler { r.Header.Set(api.HeaderSamBiscuit, base64.StdEncoding.EncodeToString(biscuitBytes)) - // Forwarded, not stripped: the agent claim is what lets the peer at the - // other end authorize and audit the agent rather than just this node. - // Replacing it with what the local gateway said also drops any value a - // caller that is not the gateway tried to set. - if agentID := agentFromLocalGateway(r); agentID != "" { - r.Header.Set(api.HeaderSamAgent, agentID) - } else { - r.Header.Del(api.HeaderSamAgent) - } - // Strip the local sidecar gate header before forwarding off-node; a caller-supplied // "Authorization" header passes straight through untouched as the destination's own credential. r.Header.Del(api.HeaderSamAuthentication) diff --git a/internal/sambox/bundle.go b/internal/sambox/bundle.go deleted file mode 100644 index 502637c6..00000000 --- a/internal/sambox/bundle.go +++ /dev/null @@ -1,129 +0,0 @@ -// 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. - -package sambox - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v2" - - "github.com/google/sam/api" -) - -// An agent bundle is what the platform declares about one agent. Its canonical -// home is a file in the agent's own state directory, so that suspending an -// agent on one host and resuming it on another carries it along with no extra -// machinery: the gateway on the new host reads the same bundle and asserts the -// same identity. -// -// Parsing is strict. A bundle is a security document, and a typo in a field -// name that silently parsed as "absent" would hand an agent broader access than -// intended, or an identity nobody granted it. - -// AgentBundle is the parsed form of that file. -type AgentBundle struct { - Version string `yaml:"version"` - Agent AgentIdentity `yaml:"agent"` - Egress BundleEgress `yaml:"egress"` - - // Serves is the one mesh service this agent provides: itself, as an A2A - // agent. The name is the platform's grant and the port is its contract - // with the agent (like $PORT on a serverless runtime); the agent binds it - // when ready, and everything else about serving -- capabilities, skills, - // negotiation -- lives on the agent's own card, inside the A2A protocol. - // Tools (mcp://) and models (inference://) are operator workloads declared - // in a node's configuration, never agent ingress. - Serves *BundleServes `yaml:"serves,omitempty"` - - // egress is the compiled form of Egress.Allow, built during loading so a - // malformed allowlist fails at startup rather than on an agent's first - // request. - egress *EgressPolicy -} - -// BundleServes contracts the agent's own a2a service: its mesh name and the -// sandbox port it must bind. -type BundleServes struct { - Name string `yaml:"name"` - Port int `yaml:"port"` -} - -// AgentIdentity names the principal the gateway asserts for this sandbox. -type AgentIdentity struct { - // ID is the canonical mesh identifier, without the "agent:" prefix. - ID string `yaml:"id"` - - // ExternalID is the platform's own identifier, kept verbatim: the - // translation into ID is not always reversible, and an auditor needs the - // value the platform actually issued. When credentials are verified, it is - // also the subject the credential has to attest. - ExternalID string `yaml:"external_id"` - - // Credential is the path to the credential the platform issued this - // workload, such as a projected Kubernetes service-account token. It backs - // the claim the rest of this file makes; see credential.go. - Credential string `yaml:"credential"` -} - -// BundleEgress is the agent's allowance outside the mesh. Absent means none. -type BundleEgress struct { - Allow []string `yaml:"allow"` -} - -// LoadAgentBundle reads and validates a bundle. -func LoadAgentBundle(path string) (*AgentBundle, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("reading the agent bundle: %w", err) - } - - var bundle AgentBundle - // Strict: an unrecognised field is a mistake worth failing on, and it is - // also how a bundle written for a later version announces itself. - if err := yaml.UnmarshalStrict(data, &bundle); err != nil { - return nil, fmt.Errorf("parsing the agent bundle %s: %w", path, err) - } - - if bundle.Version != BundleVersion { - return nil, fmt.Errorf("agent bundle %s has version %q, want %q", path, bundle.Version, BundleVersion) - } - if err := api.ValidateAgentID(bundle.Agent.ID); err != nil { - return nil, fmt.Errorf("agent bundle %s: %w", path, err) - } - - policy, err := NewEgressPolicy(bundle.Egress.Allow) - if err != nil { - return nil, fmt.Errorf("agent bundle %s: %w", path, err) - } - bundle.egress = policy - - if bundle.Serves != nil { - if err := api.ValidateServiceFormat("a2a://" + bundle.Serves.Name); err != nil { - return nil, fmt.Errorf("agent bundle %s: serves: %w", path, err) - } - if bundle.Serves.Port < 1 || bundle.Serves.Port > 65535 { - return nil, fmt.Errorf("agent bundle %s: serves: port %d is not a port", path, bundle.Serves.Port) - } - } - - return &bundle, nil -} - -// BundleVersion is the only bundle version this gateway understands. -const BundleVersion = "v1" - -// EgressPolicy returns the compiled allowlist. -func (b *AgentBundle) EgressPolicy() *EgressPolicy { return b.egress } diff --git a/internal/sambox/bundle_test.go b/internal/sambox/bundle_test.go deleted file mode 100644 index 3f428a0d..00000000 --- a/internal/sambox/bundle_test.go +++ /dev/null @@ -1,155 +0,0 @@ -// 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. - -package sambox - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func writeBundle(t *testing.T, content string) string { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "agent.yaml") - if err := os.WriteFile(path, []byte(content), 0600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - return path -} - -func TestLoadAgentBundle(t *testing.T) { - path := writeBundle(t, ` -version: v1 -agent: - id: reviewer-7.prod.acme.example - external_id: spiffe://acme.example/prod/reviewer-7 -egress: - allow: - - api.github.com - - "*.pypi.org" -`) - - bundle, err := LoadAgentBundle(path) - if err != nil { - t.Fatalf("LoadAgentBundle: %v", err) - } - if bundle.Agent.ID != "reviewer-7.prod.acme.example" { - t.Errorf("agent id = %q", bundle.Agent.ID) - } - if bundle.Agent.ExternalID != "spiffe://acme.example/prod/reviewer-7" { - t.Errorf("external id = %q, want it kept verbatim", bundle.Agent.ExternalID) - } - if !bundle.EgressPolicy().Allows("files.pypi.org") { - t.Error("compiled egress policy does not allow files.pypi.org") - } - if bundle.EgressPolicy().Allows("pypi.org") { - t.Error("a wildcard covered its parent domain") - } -} - -func TestLoadAgentBundleWithNoEgressAllowsNothing(t *testing.T) { - path := writeBundle(t, ` -version: v1 -agent: - id: reviewer.acme.example -`) - - bundle, err := LoadAgentBundle(path) - if err != nil { - t.Fatalf("LoadAgentBundle: %v", err) - } - for _, host := range []string{"api.github.com", "example.com", "127.0.0.1"} { - if bundle.EgressPolicy().Allows(host) { - t.Errorf("a bundle declaring no egress allowed %s", host) - } - } -} - -// TestLoadAgentBundleRejects covers the reason parsing is strict: a bundle is a -// security document, and a field that silently reads as absent grants either -// more access than intended or an identity nobody issued. -func TestLoadAgentBundleRejects(t *testing.T) { - tests := []struct { - name string - content string - wantErr string - }{ - { - name: "no version", - content: "agent:\n id: reviewer.acme.example\n", - wantErr: "version", - }, - { - name: "a version this gateway does not understand", - content: "version: v2\nagent:\n id: reviewer.acme.example\n", - wantErr: "version", - }, - { - name: "no agent id", - content: "version: v1\nagent:\n external_id: spiffe://acme.example/x\n", - wantErr: "empty", - }, - { - name: "an agent id with no authority", - content: "version: v1\nagent:\n id: reviewer\n", - wantErr: "authority", - }, - { - name: "an agent id that is a pattern", - content: "version: v1\nagent:\n id: \"*.acme.example\"\n", - wantErr: "wildcard", - }, - { - name: "a misspelled field", - content: "version: v1\nagent:\n id: reviewer.acme.example\negres:\n allow: [api.github.com]\n", - wantErr: "egres", - }, - { - name: "a plausible misspelling of a real field", - content: "version: v1\nagent:\n id: reviewer.acme.example\n credentials: /var/run/secrets/token\n", - wantErr: "credentials", - }, - { - name: "an ambiguous egress entry", - content: "version: v1\nagent:\n id: reviewer.acme.example\negress:\n allow: [\"api.*.com\"]\n", - wantErr: "wildcard", - }, - { - name: "not yaml at all", - content: "\tthis is not yaml\n", - wantErr: "parsing", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := LoadAgentBundle(writeBundle(t, tc.content)) - if err == nil { - t.Fatalf("LoadAgentBundle accepted %q, want an error", tc.content) - } - if !strings.Contains(err.Error(), tc.wantErr) { - t.Errorf("error = %v, want it to mention %q", err, tc.wantErr) - } - }) - } -} - -func TestLoadAgentBundleMissingFile(t *testing.T) { - if _, err := LoadAgentBundle(filepath.Join(t.TempDir(), "absent.yaml")); err == nil { - t.Fatal("LoadAgentBundle accepted a missing file, want an error") - } -} diff --git a/internal/sambox/capsule.go b/internal/sambox/capsule.go deleted file mode 100644 index 9b0160bd..00000000 --- a/internal/sambox/capsule.go +++ /dev/null @@ -1,136 +0,0 @@ -// 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. - -package sambox - -import ( - "bufio" - "bytes" - "encoding/binary" - "errors" - "fmt" - "io" - "sync" -) - -// HTTP capsules (RFC 9297) frame datagrams on a reliable stream, which is the -// form connect-udp (RFC 9298) takes when the transport is not QUIC — here, a -// Unix socket or vsock. This codec is deliberately standalone: the guest side -// lives in the tun2connect library, but importing that library would pull a -// userspace TCP stack into the module every SAM binary builds from, and the -// boundary needs sixty lines of framing, not a netstack. - -// capsuleTypeDatagram carries one HTTP Datagram, whose payload for connect-udp -// is a context ID (0) plus the UDP payload (RFC 9298 section 5). -const capsuleTypeDatagram = 0x00 - -// maxCapsulePayload bounds a peer's declared capsule length so a sandbox -// cannot make the boundary allocate unbounded memory. -const maxCapsulePayload = 1 << 16 - -// varints are QUIC variable-length integers (RFC 9000 section 16). - -func appendVarint(b []byte, v uint64) []byte { - switch { - case v < 1<<6: - return append(b, byte(v)) - case v < 1<<14: - return binary.BigEndian.AppendUint16(b, uint16(v)|0x4000) - case v < 1<<30: - return binary.BigEndian.AppendUint32(b, uint32(v)|0x8000_0000) - case v < 1<<62: - return binary.BigEndian.AppendUint64(b, v|0xc000_0000_0000_0000) - default: - panic("varint overflow") - } -} - -func readVarint(r io.ByteReader) (uint64, error) { - b0, err := r.ReadByte() - if err != nil { - return 0, err - } - v := uint64(b0 & 0x3f) - for i := 1; i < 1<<(b0>>6); i++ { - b, err := r.ReadByte() - if err != nil { - if err == io.EOF { - err = io.ErrUnexpectedEOF - } - return 0, err - } - v = v<<8 | uint64(b) - } - return v, nil -} - -// CapsuleStream frames HTTP Datagrams on a reliable stream. It is exported so -// tests and non-tun2connect clients can speak the boundary's UDP form. -type CapsuleStream struct { - wmu sync.Mutex - w io.Writer - r *bufio.Reader -} - -func NewCapsuleStream(rw io.ReadWriter) *CapsuleStream { - return &CapsuleStream{w: rw, r: bufio.NewReader(rw)} -} - -// WriteDatagram sends one UDP payload as a DATAGRAM capsule with context ID 0. -// Safe for concurrent writers. -func (s *CapsuleStream) WriteDatagram(p []byte) error { - buf := make([]byte, 0, len(p)+8) - buf = appendVarint(buf, capsuleTypeDatagram) - buf = appendVarint(buf, uint64(len(p))+1) // +1: the context ID below - buf = appendVarint(buf, 0) - buf = append(buf, p...) - s.wmu.Lock() - defer s.wmu.Unlock() - _, err := s.w.Write(buf) - return err -} - -// ReadDatagram returns the next UDP payload, skipping capsule types and -// datagram contexts it does not understand, as RFC 9297 requires. -func (s *CapsuleStream) ReadDatagram() ([]byte, error) { - for { - ctype, err := readVarint(s.r) - if err != nil { - return nil, err - } - clen, err := readVarint(s.r) - if err != nil { - return nil, err - } - if clen > maxCapsulePayload { - return nil, fmt.Errorf("sambox: capsule of %d bytes exceeds limit", clen) - } - value := make([]byte, clen) - if _, err := io.ReadFull(s.r, value); err != nil { - return nil, err - } - if ctype != capsuleTypeDatagram { - continue - } - rd := bytes.NewReader(value) - ctxID, err := readVarint(rd) - if err != nil { - return nil, errors.New("sambox: malformed DATAGRAM capsule") - } - if ctxID != 0 { - continue - } - return value[len(value)-rd.Len():], nil - } -} diff --git a/internal/sambox/connect.go b/internal/sambox/connect.go deleted file mode 100644 index 54c182c9..00000000 --- a/internal/sambox/connect.go +++ /dev/null @@ -1,404 +0,0 @@ -// 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. - -package sambox - -import ( - "bufio" - "context" - "encoding/base64" - "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "net/netip" - "net/url" - "strconv" - "strings" - "sync" - "time" -) - -// Named HTTP tunnels are the sandbox boundary protocol: authority-form CONNECT -// (RFC 9110) for TCP, connect-udp (RFC 9298) with capsules (RFC 9297) for UDP. -// A tunnel request carries the destination *name*, so egress policy is decided -// on "api.github.com" rather than on an address that says nothing about who is -// being talked to — the property the old SOCKS5 boundary was chosen for, kept. -// -// What CONNECT adds is symmetry and headroom. The reverse channel into a -// sandbox already speaks `CONNECT `, so with this the boundary is one -// protocol in both directions; a refusal is a status code with a -// Boundary-Reason header rather than a bare reply byte; headers are the -// extension point identity and tracing arrive through; and connect-udp gives -// UDP a *named*, policy-checked shape, where SOCKS5's UDP ASSOCIATE never fit -// a one-socket boundary at all. The guest side is the tun2connect library, -// and `curl --proxy` speaks the TCP half of it natively. - -// handshakeTimeout bounds reading the tunnel request only. Once a flow is -// established it may stream for as long as it likes, so no deadline survives -// into the relay. -const handshakeTimeout = 10 * time.Second - -// Errors a Dialer returns to select a refusal status. Anything else becomes a -// general failure, which is the right default: an unrecognised failure must -// not be reported to a sandbox as a precise diagnostic. -var ( - // ErrNotAllowed is a policy denial: the destination is not permitted. - ErrNotAllowed = errors.New("connection not allowed by ruleset") - - // ErrHostUnreachable means the destination could not be resolved or routed. - ErrHostUnreachable = errors.New("host unreachable") - - // ErrConnectionRefused means the destination actively refused the flow. - ErrConnectionRefused = errors.New("connection refused") -) - -// Destination is a requested target exactly as it arrived on the sandbox -// boundary. Name is a domain when the client sent one, which is the case for -// every flow that came through tun2connect's virtual DNS; a literal address -// arrives when a client dialled an IP directly, and IsName says which. -type Destination struct { - Name string - Port uint16 - IsName bool - - // Network is "tcp" for a CONNECT tunnel and "udp" for a connect-udp - // session. Empty means "tcp", so the zero value stays the common case. - Network string -} - -// Address renders the destination as a dial target. -func (d Destination) Address() string { - return net.JoinHostPort(d.Name, strconv.Itoa(int(d.Port))) -} - -func (d Destination) String() string { return d.Address() } - -// network is Network with the zero value made explicit. -func (d Destination) network() string { - if d.Network == "" { - return "tcp" - } - return d.Network -} - -// Credentials are the Proxy-Authorization Basic username and password. When -// one sam-box multiplexes several agents over a single socket, this is how a -// flow says which agent it belongs to; the password is never logged. -type Credentials struct { - Username string - Password string -} - -// Dialer decides whether a requested destination may be reached and opens it. -// It is the single policy enforcement point on the sandbox boundary. -type Dialer interface { - DialDestination(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) -} - -// DialerFunc adapts a function to Dialer. -type DialerFunc func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) - -func (f DialerFunc) DialDestination(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - return f(ctx, creds, dst) -} - -// ConnectServer serves the sandbox-facing side of the boundary. -type ConnectServer struct { - // Dialer is required. - Dialer Dialer - - // Authenticate, when set, makes Proxy-Authorization Basic credentials the - // only acceptable greeting: a client that offers none is answered 407 - // rather than silently downgraded to an anonymous flow. - Authenticate func(Credentials) error -} - -// Serve accepts connections until the listener fails or ctx is cancelled. -func (s *ConnectServer) Serve(ctx context.Context, l net.Listener) error { - if s.Dialer == nil { - return errors.New("sambox: ConnectServer requires a Dialer") - } - - go func() { - <-ctx.Done() - _ = l.Close() - }() - - var wg sync.WaitGroup - defer wg.Wait() - - for { - conn, err := l.Accept() - if err != nil { - if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { - return nil - } - return err - } - wg.Add(1) - go func() { - defer wg.Done() - // Cancelling must drop flows, not wait them out: an established - // relay only ends when one side closes, so an idle keep-alive - // connection would otherwise hold shutdown open until some other - // timeout fires. - stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) - defer stop() - s.handle(ctx, conn) - }() - } -} - -func (s *ConnectServer) handle(ctx context.Context, conn net.Conn) { - defer func() { _ = conn.Close() }() - - if err := conn.SetDeadline(time.Now().Add(handshakeTimeout)); err != nil { - return - } - - br := bufio.NewReader(conn) - req, err := http.ReadRequest(br) - if err != nil { - // Not HTTP at all: there is nothing well-formed to answer with. - return - } - - creds, err := s.credentials(req) - if err != nil { - writeRefusal(conn, http.StatusProxyAuthRequired, "credentials required", - "Proxy-Authenticate: Basic realm=\"sam-box\"") - return - } - - var dst Destination - switch { - case req.Method == http.MethodConnect: - dst, err = connectDestination(req.Host) - case req.Method == http.MethodGet && strings.EqualFold(req.Header.Get("Upgrade"), "connect-udp"): - // EscapedPath, not Path: the parser has already unescaped Path, so a - // %2F inside a segment would change the segment count. The segments - // are unescaped individually after splitting. - dst, err = masqueDestination(req.URL.EscapedPath()) - default: - writeRefusal(conn, http.StatusMethodNotAllowed, "the boundary speaks CONNECT and connect-udp only") - return - } - if err != nil { - writeRefusal(conn, http.StatusBadRequest, err.Error()) - return - } - - // The request is read. Dialling a mesh destination can involve discovery, - // so it must not inherit the handshake deadline; bounding it is the - // Dialer's job, through the context it is given. - if err := conn.SetDeadline(time.Time{}); err != nil { - return - } - - upstream, err := s.Dialer.DialDestination(ctx, creds, dst) - if err != nil { - status, reason := refusalFor(err) - writeRefusal(conn, status, reason) - return - } - defer func() { _ = upstream.Close() }() - - if dst.network() == "udp" { - if _, err := io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+ - "Connection: Upgrade\r\nUpgrade: connect-udp\r\nCapsule-Protocol: ?1\r\n\r\n"); err != nil { - return - } - pumpUDP(NewCapsuleStream(&bufConn{Conn: conn, br: br}), upstream) - return - } - - if _, err := io.WriteString(conn, "HTTP/1.1 200 OK\r\n\r\n"); err != nil { - return - } - // br first: it may hold bytes the client pipelined behind the request. - relay(&bufConn{Conn: conn, br: br}, upstream) -} - -// credentials parses Proxy-Authorization and applies Authenticate when set. -func (s *ConnectServer) credentials(req *http.Request) (*Credentials, error) { - creds := parseProxyBasicAuth(req.Header.Get("Proxy-Authorization")) - if s.Authenticate == nil { - return creds, nil - } - if creds == nil { - return nil, errors.New("credentials required") - } - if err := s.Authenticate(*creds); err != nil { - log.Printf("sambox: boundary authentication rejected for user %q", creds.Username) - return nil, err - } - return creds, nil -} - -// parseProxyBasicAuth decodes "Basic ", or returns nil. -func parseProxyBasicAuth(header string) *Credentials { - fields := strings.Fields(header) - if len(fields) != 2 || !strings.EqualFold(fields[0], "Basic") { - return nil - } - decoded, err := base64.StdEncoding.DecodeString(fields[1]) - if err != nil { - return nil - } - username, password, ok := strings.Cut(string(decoded), ":") - if !ok { - return nil - } - return &Credentials{Username: username, Password: password} -} - -// connectDestination parses the authority-form CONNECT target. -func connectDestination(hostport string) (Destination, error) { - host, rawPort, err := net.SplitHostPort(hostport) - if err != nil || host == "" { - return Destination{}, fmt.Errorf("malformed CONNECT target %q", hostport) - } - port, err := strconv.ParseUint(rawPort, 10, 16) - if err != nil { - return Destination{}, fmt.Errorf("malformed CONNECT port %q", rawPort) - } - _, isAddr := parseAddr(host) - return Destination{Name: host, Port: uint16(port), IsName: !isAddr, Network: "tcp"}, nil -} - -// masqueDestination parses the default connect-udp URI template -// /.well-known/masque/udp/{host}/{port}/ (RFC 9298 section 2). -func masqueDestination(path string) (Destination, error) { - seg := strings.Split(strings.Trim(path, "/"), "/") - if len(seg) != 5 || seg[0] != ".well-known" || seg[1] != "masque" || seg[2] != "udp" { - return Destination{}, fmt.Errorf("malformed connect-udp template %q", path) - } - host, err := url.PathUnescape(seg[3]) - if err != nil || host == "" { - return Destination{}, fmt.Errorf("malformed connect-udp target host") - } - port, err := strconv.ParseUint(seg[4], 10, 16) - if err != nil { - return Destination{}, fmt.Errorf("malformed connect-udp port %q", seg[4]) - } - _, isAddr := parseAddr(host) - return Destination{Name: host, Port: uint16(port), IsName: !isAddr, Network: "udp"}, nil -} - -func parseAddr(host string) (netip.Addr, bool) { - addr, err := netip.ParseAddr(host) - return addr, err == nil -} - -// writeRefusal answers a request that will not become a tunnel. The status is -// what a plain HTTP client sees; Boundary-Reason is what a log is read -// against, and "not allowed by policy" has to be legible as a decision rather -// than looking like the mesh being broken. -func writeRefusal(conn net.Conn, status int, reason string, extraHeaders ...string) { - // The reason can quote request input, and a CR or LF in a header value - // is response splitting (CWE-113); strip them at the sink. - reason = strings.NewReplacer("\r", "", "\n", "").Replace(reason) - msg := fmt.Sprintf("HTTP/1.1 %d %s\r\nBoundary-Reason: %s\r\n", - status, http.StatusText(status), reason) - for _, h := range extraHeaders { - msg += h + "\r\n" - } - msg += "Content-Length: 0\r\n\r\n" - _, _ = io.WriteString(conn, msg) -} - -func refusalFor(err error) (int, string) { - switch { - case errors.Is(err, ErrNotAllowed): - return http.StatusForbidden, "not allowed by policy" - case errors.Is(err, ErrHostUnreachable): - return http.StatusBadGateway, "host unreachable" - case errors.Is(err, ErrConnectionRefused): - return http.StatusBadGateway, "connection refused" - default: - return http.StatusInternalServerError, "general failure" - } -} - -// pumpUDP carries one connect-udp session: capsules from the client become -// datagrams upstream and back, until either side ends the session. -func pumpUDP(cs *CapsuleStream, upstream net.Conn) { - go func() { - defer func() { _ = upstream.Close() }() - for { - p, err := cs.ReadDatagram() - if err != nil { - return - } - if _, err := upstream.Write(p); err != nil { - return - } - } - }() - buf := make([]byte, 65535) - for { - n, err := upstream.Read(buf) - if err != nil { - return - } - if cs.WriteDatagram(buf[:n]) != nil { - return - } - } -} - -// bufConn keeps bytes the request reader buffered past the header visible to -// the relay, and keeps the half-close the relay depends on reachable. -type bufConn struct { - net.Conn - br *bufio.Reader -} - -func (c *bufConn) Read(p []byte) (int, error) { return c.br.Read(p) } - -func (c *bufConn) CloseWrite() error { - if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { - return cw.CloseWrite() - } - return c.Close() -} - -func relay(client, upstream net.Conn) { - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - _, _ = io.Copy(upstream, client) - closeWrite(upstream) - }() - go func() { - defer wg.Done() - _, _ = io.Copy(client, upstream) - closeWrite(client) - }() - wg.Wait() -} - -// closeWrite propagates a half-close so a peer waiting on EOF is not left -// hanging until a timeout. -func closeWrite(conn net.Conn) { - if cw, ok := conn.(interface{ CloseWrite() error }); ok { - _ = cw.CloseWrite() - return - } - _ = conn.Close() -} diff --git a/internal/sambox/connect_test.go b/internal/sambox/connect_test.go deleted file mode 100644 index 8022f7fb..00000000 --- a/internal/sambox/connect_test.go +++ /dev/null @@ -1,638 +0,0 @@ -// 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. - -package sambox - -import ( - "bufio" - "context" - "encoding/base64" - "errors" - "io" - "net" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// startBoundary serves s on a Unix socket, the transport the sandbox boundary -// actually uses, and returns its path. -func startBoundary(t *testing.T, s *ConnectServer) string { - t.Helper() - - // Not t.TempDir(): test names make paths long enough to hit the ~108 byte - // sockaddr_un limit. - dir, err := os.MkdirTemp("", "sambox") - if err != nil { - t.Fatalf("MkdirTemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - path := filepath.Join(dir, "agent.sock") - l, err := net.Listen("unix", path) - if err != nil { - t.Fatalf("listen: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - if err := s.Serve(ctx, l); err != nil { - t.Errorf("Serve: %v", err) - } - }() - t.Cleanup(func() { - cancel() - <-done - }) - return path -} - -// startEcho returns the address of a server that echoes what it is sent. -func startEcho(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { _ = l.Close() }) - - go func() { - for { - conn, err := l.Accept() - if err != nil { - return - } - go func() { - defer func() { _ = conn.Close() }() - _, _ = io.Copy(conn, conn) - }() - } - }() - return l.Addr().String() -} - -// startUDPEcho returns the address of a datagram server that echoes. -func startUDPEcho(t *testing.T) string { - t.Helper() - pc, err := net.ListenPacket("udp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen udp: %v", err) - } - t.Cleanup(func() { _ = pc.Close() }) - - go func() { - buf := make([]byte, 65535) - for { - n, from, err := pc.ReadFrom(buf) - if err != nil { - return - } - _, _ = pc.WriteTo(buf[:n], from) - } - }() - return pc.LocalAddr().String() -} - -// dialRaw opens a plain connection to the boundary, for the cases a -// well-behaved client library will never produce. -func dialRaw(t *testing.T, path string) net.Conn { - t.Helper() - conn, err := net.Dial("unix", path) - if err != nil { - t.Fatalf("dial: %v", err) - } - t.Cleanup(func() { _ = conn.Close() }) - if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { - t.Fatalf("SetDeadline: %v", err) - } - return conn -} - -func basicAuth(user, pass string) string { - return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) -} - -// boundaryDialContext returns a DialContext that opens each flow as a CONNECT -// tunnel through the boundary at path, the way tun2connect does in a sandbox. -func boundaryDialContext(path string) func(ctx context.Context, network, addr string) (net.Conn, error) { - return func(ctx context.Context, _, addr string) (net.Conn, error) { - var d net.Dialer - conn, err := d.DialContext(ctx, "unix", path) - if err != nil { - return nil, err - } - req := &http.Request{Method: http.MethodConnect, URL: &url.URL{Host: addr}, Host: addr, Header: make(http.Header)} - if err := req.Write(conn); err != nil { - _ = conn.Close() - return nil, err - } - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, req) - if err != nil { - _ = conn.Close() - return nil, err - } - if resp.StatusCode != http.StatusOK { - _ = conn.Close() - return nil, errors.New("boundary refused: " + resp.Status) - } - return &bufConn{Conn: conn, br: br}, nil - } -} - -// connectRoundTrip writes an authority-form CONNECT for hostport and returns -// the connection, the buffered reader holding anything past the response, and -// the response itself. -func connectRoundTrip(t *testing.T, path, hostport string, header http.Header) (net.Conn, *bufio.Reader, *http.Response) { - t.Helper() - conn := dialRaw(t, path) - req := &http.Request{ - Method: http.MethodConnect, - URL: &url.URL{Host: hostport}, - Host: hostport, - Header: header, - } - if req.Header == nil { - req.Header = make(http.Header) - } - if err := req.Write(conn); err != nil { - t.Fatalf("write CONNECT: %v", err) - } - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, req) - if err != nil { - t.Fatalf("read CONNECT response: %v", err) - } - return conn, br, resp -} - -// connectThrough opens a CONNECT tunnel and fails the test on refusal. -func connectThrough(t *testing.T, path, hostport string) net.Conn { - t.Helper() - conn, br, resp := connectRoundTrip(t, path, hostport, nil) - if resp.StatusCode != http.StatusOK { - t.Fatalf("CONNECT %s = %s (reason %q), want 200", hostport, resp.Status, resp.Header.Get("Boundary-Reason")) - } - return &bufConn{Conn: conn, br: br} -} - -// TestConnectPreservesDestinationName is the property the whole boundary rests -// on: policy must see the name the agent asked for, never a resolved address. -func TestConnectPreservesDestinationName(t *testing.T) { - echo := startEcho(t) - - seen := make(chan Destination, 1) - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - seen <- dst - return net.Dial("tcp", echo) - }), - }) - - conn := connectThrough(t, path, "api.github.com:443") - defer func() { _ = conn.Close() }() - - if _, err := conn.Write([]byte("ping")); err != nil { - t.Fatalf("write: %v", err) - } - got := make([]byte, 4) - if _, err := io.ReadFull(conn, got); err != nil { - t.Fatalf("read: %v", err) - } - if string(got) != "ping" { - t.Errorf("echo = %q, want %q", got, "ping") - } - - dst := <-seen - if !dst.IsName { - t.Errorf("destination %+v was not reported as a name", dst) - } - if dst.Name != "api.github.com" || dst.Port != 443 || dst.network() != "tcp" { - t.Errorf("destination = %s over %s, want api.github.com:443 over tcp", dst, dst.network()) - } -} - -func TestConnectDeniedByPolicy(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - return nil, ErrNotAllowed - }), - }) - - _, _, resp := connectRoundTrip(t, path, "evil.example:80", nil) - if resp.StatusCode != http.StatusForbidden { - t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) - } - if reason := resp.Header.Get("Boundary-Reason"); reason != "not allowed by policy" { - t.Errorf("Boundary-Reason = %q, want a legible policy denial", reason) - } -} - -func TestNonTunnelMethodsAreRefused(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - t.Error("dialer must not be reached for a non-tunnel request") - return nil, errors.New("unreachable") - }), - }) - - // A GET without the connect-udp upgrade is a client that thinks this is a - // web server; the boundary is not one. - for _, method := range []string{http.MethodGet, http.MethodPost} { - conn := dialRaw(t, path) - req, err := http.NewRequest(method, "http://boundary/anything", nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) - } - if err := req.Write(conn); err != nil { - t.Fatalf("write request: %v", err) - } - resp, err := http.ReadResponse(bufio.NewReader(conn), req) - if err != nil { - t.Fatalf("read response: %v", err) - } - if resp.StatusCode != http.StatusMethodNotAllowed { - t.Errorf("%s: status = %d, want %d", method, resp.StatusCode, http.StatusMethodNotAllowed) - } - } -} - -func TestMalformedConnectTargetIsRefused(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - t.Error("dialer must not be reached for a malformed target") - return nil, errors.New("unreachable") - }), - }) - - // Authority form requires host:port; a bare host must be refused rather - // than guessed at. - _, _, resp := connectRoundTrip(t, path, "api.github.com", nil) - if resp.StatusCode != http.StatusBadRequest { - t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) - } -} - -func TestConnectByIPLiteralIsNotReportedAsAName(t *testing.T) { - echo := startEcho(t) - - seen := make(chan Destination, 1) - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - seen <- dst - return net.Dial("tcp", echo) - }), - }) - - conn := connectThrough(t, path, "192.0.2.10:443") - defer func() { _ = conn.Close() }() - - dst := <-seen - if dst.IsName { - t.Errorf("destination %+v was reported as a name", dst) - } - if dst.Name != "192.0.2.10" || dst.Port != 443 { - t.Errorf("destination = %s, want 192.0.2.10:443", dst) - } -} - -// TestAuthenticationIsNotDowngraded pins that a server expecting credentials -// refuses an anonymous client instead of serving it unidentified. -func TestAuthenticationIsNotDowngraded(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - t.Error("dialer must not be reached for an unauthenticated client") - return nil, errors.New("unreachable") - }), - Authenticate: func(Credentials) error { return nil }, - }) - - _, _, resp := connectRoundTrip(t, path, "api.github.com:443", nil) - if resp.StatusCode != http.StatusProxyAuthRequired { - t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusProxyAuthRequired) - } - if resp.Header.Get("Proxy-Authenticate") == "" { - t.Error("a 407 must say how to authenticate") - } -} - -func TestAuthenticatedFlowCarriesCredentials(t *testing.T) { - echo := startEcho(t) - - seen := make(chan *Credentials, 1) - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - seen <- creds - return net.Dial("tcp", echo) - }), - Authenticate: func(c Credentials) error { - if c.Username != "reviewer-7.prod.acme.example" { - return errors.New("unknown agent") - } - return nil - }, - }) - - header := make(http.Header) - header.Set("Proxy-Authorization", basicAuth("reviewer-7.prod.acme.example", "admission-token")) - _, _, resp := connectRoundTrip(t, path, "code-reviewer.mcp.sam.alt:80", header) - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } - - creds := <-seen - if creds == nil { - t.Fatal("dialer received no credentials") - } - if creds.Username != "reviewer-7.prod.acme.example" || creds.Password != "admission-token" { - t.Errorf("credentials = %+v, want the agent id and its admission token", creds) - } -} - -func TestRejectedCredentialsFailTheHandshake(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - t.Error("dialer must not be reached when authentication fails") - return nil, errors.New("unreachable") - }), - Authenticate: func(Credentials) error { return errors.New("unknown agent") }, - }) - - header := make(http.Header) - header.Set("Proxy-Authorization", basicAuth("bar", "nope")) - _, _, resp := connectRoundTrip(t, path, "api.github.com:443", header) - if resp.StatusCode != http.StatusProxyAuthRequired { - t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusProxyAuthRequired) - } -} - -func TestNonHTTPGreetingIsDropped(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - t.Error("dialer must not be reached for a non-HTTP client") - return nil, errors.New("unreachable") - }), - }) - - conn := dialRaw(t, path) - // A SOCKS5 greeting, which is what the previous boundary spoke: there is - // nothing well-formed to answer it with. The newline lets the boundary - // judge the line now rather than waiting out the handshake deadline. - if _, err := conn.Write([]byte("\x05\x01\x00\r\n")); err != nil { - t.Fatalf("write greeting: %v", err) - } - if _, err := io.ReadFull(conn, make([]byte, 1)); !errors.Is(err, io.EOF) { - t.Errorf("read after non-HTTP greeting = %v, want EOF", err) - } -} - -// TestConnectUDPRoundTrip pins the boundary's UDP shape: a connect-udp upgrade -// naming the destination, then DATAGRAM capsules both ways. -func TestConnectUDPRoundTrip(t *testing.T) { - echo := startUDPEcho(t) - - seen := make(chan Destination, 1) - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - seen <- dst - return net.Dial("udp", echo) - }), - }) - - conn := dialRaw(t, path) - req := &http.Request{ - Method: http.MethodGet, - URL: &url.URL{Scheme: "http", Host: "boundary", Path: "/.well-known/masque/udp/dns.example/53/"}, - Host: "boundary", - Header: make(http.Header), - } - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "connect-udp") - req.Header.Set("Capsule-Protocol", "?1") - if err := req.Write(conn); err != nil { - t.Fatalf("write connect-udp: %v", err) - } - br := bufio.NewReader(conn) - resp, err := http.ReadResponse(br, req) - if err != nil { - t.Fatalf("read response: %v", err) - } - if resp.StatusCode != http.StatusSwitchingProtocols || resp.Header.Get("Upgrade") != "connect-udp" { - t.Fatalf("response = %s (Upgrade %q), want 101 connect-udp", resp.Status, resp.Header.Get("Upgrade")) - } - - dst := <-seen - if dst.Name != "dns.example" || dst.Port != 53 || dst.network() != "udp" || !dst.IsName { - t.Errorf("destination = %+v, want dns.example:53 over udp as a name", dst) - } - - cs := NewCapsuleStream(&bufConn{Conn: conn, br: br}) - if err := cs.WriteDatagram([]byte("ping")); err != nil { - t.Fatalf("WriteDatagram: %v", err) - } - got, err := cs.ReadDatagram() - if err != nil { - t.Fatalf("ReadDatagram: %v", err) - } - if string(got) != "ping" { - t.Errorf("echo = %q, want %q", got, "ping") - } -} - -// TestMasqueDestinationSurvivesEscapedSlashes pins the parsing order: split -// the escaped path first, unescape each segment after, so a %2F inside the -// host cannot change the segment count. -func TestMasqueDestinationSurvivesEscapedSlashes(t *testing.T) { - dst, err := masqueDestination("/.well-known/masque/udp/odd%2Fname/53/") - if err != nil { - t.Fatalf("masqueDestination: %v", err) - } - if dst.Name != "odd/name" || dst.Port != 53 { - t.Errorf("destination = %+v, want odd/name:53", dst) - } -} - -func TestParseProxyBasicAuth(t *testing.T) { - valid := basicAuth("user", "pass") - tests := []struct { - name string - header string - want *Credentials - }{ - {"well formed", valid, &Credentials{Username: "user", Password: "pass"}}, - {"case-insensitive scheme", "basic " + strings.TrimPrefix(valid, "Basic "), &Credentials{Username: "user", Password: "pass"}}, - {"extra whitespace", "Basic " + strings.TrimPrefix(valid, "Basic "), &Credentials{Username: "user", Password: "pass"}}, - {"empty", "", nil}, - {"wrong scheme", "Bearer token", nil}, - {"not base64", "Basic !!!", nil}, - {"no colon", "Basic " + base64.StdEncoding.EncodeToString([]byte("nocolon")), nil}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := parseProxyBasicAuth(tc.header) - if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) { - t.Errorf("parseProxyBasicAuth(%q) = %+v, want %+v", tc.header, got, tc.want) - } - }) - } -} - -// TestRefusalReasonCannotSplitTheResponse pins the CWE-113 fix: a reason -// carrying CRLF must not become a second header or response. -func TestRefusalReasonCannotSplitTheResponse(t *testing.T) { - client, server := net.Pipe() - defer func() { _ = client.Close() }() - go func() { - writeRefusal(server, http.StatusForbidden, "bad\r\nInjected: header\r\n\r\nHTTP/1.1 200 OK") - _ = server.Close() - }() - - resp, err := http.ReadResponse(bufio.NewReader(client), nil) - if err != nil { - t.Fatalf("ReadResponse: %v", err) - } - if resp.StatusCode != http.StatusForbidden { - t.Errorf("status = %d, want 403", resp.StatusCode) - } - if got := resp.Header.Get("Injected"); got != "" { - t.Errorf("Injected header = %q, the reason split the response", got) - } -} - -func TestConnectUDPDeniedByPolicy(t *testing.T) { - path := startBoundary(t, &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - return nil, ErrNotAllowed - }), - }) - - conn := dialRaw(t, path) - req := &http.Request{ - Method: http.MethodGet, - URL: &url.URL{Scheme: "http", Host: "boundary", Path: "/.well-known/masque/udp/evil.example/53/"}, - Host: "boundary", - Header: make(http.Header), - } - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "connect-udp") - req.Header.Set("Capsule-Protocol", "?1") - if err := req.Write(conn); err != nil { - t.Fatalf("write connect-udp: %v", err) - } - resp, err := http.ReadResponse(bufio.NewReader(conn), req) - if err != nil { - t.Fatalf("read response: %v", err) - } - if resp.StatusCode != http.StatusForbidden { - t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) - } -} - -// TestServeDropsFlowsOnCancel pins that shutdown is prompt. An established -// relay only ends when one side closes, so without this an idle keep-alive -// connection holds the gateway open until some unrelated timeout fires. -func TestServeDropsFlowsOnCancel(t *testing.T) { - echo := startEcho(t) - - dir, err := os.MkdirTemp("", "sambox") - if err != nil { - t.Fatalf("MkdirTemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - socket := filepath.Join(dir, "agent.sock") - - l, err := net.Listen("unix", socket) - if err != nil { - t.Fatalf("listen: %v", err) - } - - server := &ConnectServer{ - Dialer: DialerFunc(func(ctx context.Context, creds *Credentials, dst Destination) (net.Conn, error) { - return net.Dial("tcp", echo) - }), - } - - ctx, cancel := context.WithCancel(context.Background()) - served := make(chan error, 1) - go func() { served <- server.Serve(ctx, l) }() - - // Serve is racing this dial; retry briefly until the listener answers. - var conn net.Conn - for range 50 { - conn, _, _ = func() (net.Conn, *bufio.Reader, *http.Response) { - c, err := net.Dial("unix", socket) - if err != nil { - time.Sleep(10 * time.Millisecond) - return nil, nil, nil - } - req := &http.Request{Method: http.MethodConnect, URL: &url.URL{Host: "api.github.com:443"}, Host: "api.github.com:443", Header: make(http.Header)} - if err := req.Write(c); err != nil { - _ = c.Close() - return nil, nil, nil - } - br := bufio.NewReader(c) - if _, err := http.ReadResponse(br, req); err != nil { - _ = c.Close() - return nil, nil, nil - } - return c, br, nil - }() - if conn != nil { - break - } - } - if conn == nil { - t.Fatal("could not establish a flow through the boundary") - } - defer func() { _ = conn.Close() }() - - // The flow is established and idle, which is the case that used to hang. - cancel() - select { - case err := <-served: - if err != nil { - t.Fatalf("Serve: %v", err) - } - case <-time.After(10 * time.Second): - t.Fatal("Serve did not return after cancel; an idle flow is holding shutdown open") - } -} - -func TestRefusalFor(t *testing.T) { - tests := []struct { - name string - err error - wantStatus int - }{ - {"policy denial", ErrNotAllowed, http.StatusForbidden}, - {"unreachable", ErrHostUnreachable, http.StatusBadGateway}, - {"refused", ErrConnectionRefused, http.StatusBadGateway}, - {"wrapped denial", errors.Join(errors.New("context"), ErrNotAllowed), http.StatusForbidden}, - {"anything else stays generic", errors.New("boom"), http.StatusInternalServerError}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got, _ := refusalFor(tc.err); got != tc.wantStatus { - t.Errorf("refusalFor(%v) = %d, want %d", tc.err, got, tc.wantStatus) - } - }) - } -} diff --git a/internal/sambox/credential.go b/internal/sambox/credential.go deleted file mode 100644 index 8014cd09..00000000 --- a/internal/sambox/credential.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "fmt" - "os" - "strings" - - "github.com/coreos/go-oidc/v3/oidc" - - "github.com/google/sam/internal/identity" -) - -// Without this, a bundle is self-asserting: whoever can write the file decides -// which agent the sandbox is, and the identity the whole mesh then reasons -// about rests on a YAML field. Verification makes the bundle a claim that has -// to be backed by a credential the platform issued to that workload — a -// projected Kubernetes service-account token today. -// -// The issuer is deliberately not read from the bundle. The bundle travels with -// the agent and is therefore exactly as trustworthy as the agent; an issuer -// named there could be one the attacker controls, and self-signed credentials -// would verify perfectly. It comes from the operator instead, on the command -// line beside the socket paths. - -// WorkloadVerifier checks the credential a platform issued to a sandbox. -type WorkloadVerifier struct { - providers map[string]*oidc.Provider - audiences []string -} - -// NewWorkloadVerifier resolves the issuer, which requires reaching its -// discovery endpoint, so a misconfigured issuer fails at startup rather than -// on the first agent. -func NewWorkloadVerifier(ctx context.Context, issuer, audience string) (*WorkloadVerifier, error) { - if issuer == "" || audience == "" { - return nil, fmt.Errorf("both a credential issuer and an audience are required") - } - provider, err := oidc.NewProvider(ctx, issuer) - if err != nil { - return nil, fmt.Errorf("resolving the credential issuer %s: %w", issuer, err) - } - return &WorkloadVerifier{ - providers: map[string]*oidc.Provider{issuer: provider}, - audiences: []string{audience}, - }, nil -} - -// Verify reports whether the bundle's credential attests the identity the -// bundle claims. -// -// The check that matters is the last one: the credential's subject must be the -// external identity the bundle declares. Verifying the signature alone would -// only prove the sandbox holds *a* valid credential, which every sandbox on the -// platform does, and any of them could then claim to be any other. -func (v *WorkloadVerifier) Verify(ctx context.Context, bundle *AgentBundle) error { - if bundle.Agent.Credential == "" { - return fmt.Errorf("agent %s declares no credential, and this gateway verifies them", bundle.Agent.ID) - } - if bundle.Agent.ExternalID == "" { - return fmt.Errorf("agent %s declares no external_id, so there is nothing for its credential to attest", bundle.Agent.ID) - } - - raw, err := os.ReadFile(bundle.Agent.Credential) - if err != nil { - return fmt.Errorf("reading the credential for agent %s: %w", bundle.Agent.ID, err) - } - - claims, _, err := identity.VerifyJWT(ctx, strings.TrimSpace(string(raw)), v.audiences, v.providers) - if err != nil { - return fmt.Errorf("verifying the credential for agent %s: %w", bundle.Agent.ID, err) - } - - subject, _ := claims["sub"].(string) - if subject == "" { - return fmt.Errorf("the credential for agent %s attests no subject", bundle.Agent.ID) - } - if subject != bundle.Agent.ExternalID { - return fmt.Errorf("agent %s claims to be %q, but its credential attests %q", - bundle.Agent.ID, bundle.Agent.ExternalID, subject) - } - return nil -} diff --git a/internal/sambox/credential_test.go b/internal/sambox/credential_test.go deleted file mode 100644 index bdaa72fd..00000000 --- a/internal/sambox/credential_test.go +++ /dev/null @@ -1,247 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "math/big" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -const testKID = "test-key" - -// newMockPlatformIssuer stands in for a Kubernetes API server issuing projected -// service-account tokens. -func newMockPlatformIssuer(t *testing.T) (issuer string, key *rsa.PrivateKey) { - t.Helper() - - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - - mux := http.NewServeMux() - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - issuer = srv.URL - - mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "issuer": issuer, - "jwks_uri": issuer + "/keys", - }) - }) - mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "keys": []map[string]any{{ - "kty": "RSA", - "alg": "RS256", - "use": "sig", - "kid": testKID, - "n": base64.RawURLEncoding.EncodeToString(privKey.N.Bytes()), - "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privKey.E)).Bytes()), - }}, - }) - }) - - return issuer, privKey -} - -func signCredential(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { - t.Helper() - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = testKID - signed, err := token.SignedString(key) - if err != nil { - t.Fatalf("SignedString: %v", err) - } - return signed -} - -// bundleWithCredential writes a bundle and its credential file, returning the -// loaded bundle. -func bundleWithCredential(t *testing.T, externalID, credential string) *AgentBundle { - t.Helper() - - dir := t.TempDir() - credentialPath := filepath.Join(dir, "token") - if credential != "" { - if err := os.WriteFile(credentialPath, []byte(credential), 0600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - } - - content := "version: v1\nagent:\n id: reviewer-7.prod.acme.example\n" - if externalID != "" { - content += " external_id: " + externalID + "\n" - } - if credential != "" { - content += " credential: " + credentialPath + "\n" - } - - bundle, err := LoadAgentBundle(writeBundle(t, content)) - if err != nil { - t.Fatalf("LoadAgentBundle: %v", err) - } - return bundle -} - -func TestWorkloadVerifier(t *testing.T) { - issuer, key := newMockPlatformIssuer(t) - ctx := context.Background() - - verifier, err := NewWorkloadVerifier(ctx, issuer, "sam-mesh") - if err != nil { - t.Fatalf("NewWorkloadVerifier: %v", err) - } - - subject := "system:serviceaccount:prod:reviewer" - valid := jwt.MapClaims{ - "iss": issuer, - "aud": "sam-mesh", - "sub": subject, - "exp": time.Now().Add(time.Hour).Unix(), - } - - t.Run("a credential attesting the declared identity", func(t *testing.T) { - bundle := bundleWithCredential(t, subject, signCredential(t, key, valid)) - if err := verifier.Verify(ctx, bundle); err != nil { - t.Fatalf("Verify: %v", err) - } - }) - - // The one that matters. Every sandbox on a platform holds a valid - // credential, so a signature check alone would let any of them claim to be - // any other. - t.Run("a valid credential for somebody else", func(t *testing.T) { - other := signCredential(t, key, jwt.MapClaims{ - "iss": issuer, - "aud": "sam-mesh", - "sub": "system:serviceaccount:prod:some-other-workload", - "exp": time.Now().Add(time.Hour).Unix(), - }) - bundle := bundleWithCredential(t, subject, other) - - err := verifier.Verify(ctx, bundle) - if err == nil { - t.Fatal("Verify accepted a credential attesting a different workload") - } - if !strings.Contains(err.Error(), "some-other-workload") { - t.Errorf("error = %v, want it to name the subject actually attested", err) - } - }) - - t.Run("rejections", func(t *testing.T) { - otherIssuer, otherKey := newMockPlatformIssuer(t) - - tests := []struct { - name string - externalID string - credential string - }{ - { - name: "expired", - externalID: subject, - credential: signCredential(t, key, jwt.MapClaims{ - "iss": issuer, "aud": "sam-mesh", "sub": subject, - "exp": time.Now().Add(-time.Minute).Unix(), - }), - }, - { - name: "for a different audience", - externalID: subject, - credential: signCredential(t, key, jwt.MapClaims{ - "iss": issuer, "aud": "somebody-else", "sub": subject, - "exp": time.Now().Add(time.Hour).Unix(), - }), - }, - { - // An issuer the operator did not name. This is why the issuer - // cannot come from the bundle: an attacker who chose it would - // simply sign their own. - name: "from an issuer this gateway does not trust", - externalID: subject, - credential: signCredential(t, otherKey, jwt.MapClaims{ - "iss": otherIssuer, "aud": "sam-mesh", "sub": subject, - "exp": time.Now().Add(time.Hour).Unix(), - }), - }, - { - name: "signed by the wrong key for the right issuer", - externalID: subject, - credential: signCredential(t, otherKey, valid), - }, - { - name: "not a token at all", - externalID: subject, - credential: "not-a-jwt", - }, - { - name: "attesting no subject", - externalID: subject, - credential: signCredential(t, key, jwt.MapClaims{ - "iss": issuer, "aud": "sam-mesh", - "exp": time.Now().Add(time.Hour).Unix(), - }), - }, - { - name: "declared without an external identity to attest", - credential: signCredential(t, key, valid), - }, - { - name: "no credential at all", - externalID: subject, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - bundle := bundleWithCredential(t, tc.externalID, tc.credential) - if err := verifier.Verify(ctx, bundle); err == nil { - t.Fatal("Verify accepted it, want an error") - } - }) - } - }) -} - -func TestNewWorkloadVerifierRequiresIssuerAndAudience(t *testing.T) { - ctx := context.Background() - issuer, _ := newMockPlatformIssuer(t) - - if _, err := NewWorkloadVerifier(ctx, "", "sam-mesh"); err == nil { - t.Error("NewWorkloadVerifier accepted an empty issuer") - } - if _, err := NewWorkloadVerifier(ctx, issuer, ""); err == nil { - t.Error("NewWorkloadVerifier accepted an empty audience") - } - if _, err := NewWorkloadVerifier(ctx, "http://127.0.0.1:1/not-an-issuer", "sam-mesh"); err == nil { - t.Error("NewWorkloadVerifier accepted an unreachable issuer, want it to fail at startup") - } -} diff --git a/internal/sambox/dial.go b/internal/sambox/dial.go deleted file mode 100644 index 62188fe3..00000000 --- a/internal/sambox/dial.go +++ /dev/null @@ -1,152 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "errors" - "fmt" - "net" - "sync/atomic" - "syscall" - "time" -) - -// dialTimeout bounds opening a destination. The CONNECT layer deliberately -// drops its handshake deadline before dialling, so this is the only bound and -// it has to exist here. -const dialTimeout = 30 * time.Second - -// AgentDialer opens whatever a Route calls for. It is the only place in the -// sandbox boundary that touches the network, which keeps the routing decision -// (route.go) and the protocol (connect.go) free of I/O. -type AgentDialer struct { - // Router classifies destinations. Required. - Router *Router - - // SidecarSocket is the Unix socket of the sam-node this sandbox is attached - // to. sam-box is the node's only consumer here: an agent never reaches the - // socket, only the curated surface built on top of it (entrypoint.go). - SidecarSocket string - - // AgentID is the principal this boundary serves, asserted to the node on - // every request (api.HeaderSamAgent). Empty means the sandbox is - // unidentified, and mesh policy sees only the node it came through. - AgentID string - - // DialContext opens external destinations. Nil uses a plain net.Dialer; - // tests and future egress interception replace it. - DialContext func(ctx context.Context, network, address string) (net.Conn, error) -} - -// DialDestination implements Dialer. -func (d *AgentDialer) DialDestination(ctx context.Context, _ *Credentials, dst Destination) (net.Conn, error) { - if d.Router == nil { - return nil, errors.New("sambox: AgentDialer requires a Router") - } - - start := time.Now() - - route, err := d.Router.Route(dst) - if err != nil { - recordFlow(routeUnresolved, 0, err) - return nil, err - } - - ctx, cancel := context.WithTimeout(ctx, dialTimeout) - defer cancel() - - conn, err := d.dialRoute(ctx, route, dst) - recordFlow(route.Kind.String(), time.Since(start), err) - if err != nil { - return nil, err - } - flowsActive.Inc() - return &countedConn{Conn: conn}, nil -} - -func (d *AgentDialer) dialRoute(ctx context.Context, route Route, dst Destination) (net.Conn, error) { - switch route.Kind { - case RouteMeshEntrypoint: - return d.dialMeshEntrypoint() - case RouteExternal: - return d.dial(ctx, dst.network(), dst.Address()) - case RouteMeshService: - return d.dialMeshService(ctx, route) - default: - return nil, fmt.Errorf("sambox: unhandled route %v", route.Kind) - } -} - -// countedConn keeps the active-flow gauge honest. Both relay directions close -// their side, so the decrement has to happen exactly once. -type countedConn struct { - net.Conn - closed atomic.Bool -} - -func (c *countedConn) Close() error { - if c.closed.CompareAndSwap(false, true) { - flowsActive.Dec() - } - return c.Conn.Close() -} - -// CloseWrite keeps the half-close the relay depends on reachable through the -// wrapper. Advertising it unconditionally would be a trap: the relay falls back -// to a full close for connections that cannot half-close, and a wrapper that -// claims the capability without delivering it leaves the peer's copy blocked -// forever. So when the wrapped connection has no half-close, do what the relay -// would have done. -func (c *countedConn) CloseWrite() error { - if cw, ok := c.Conn.(interface{ CloseWrite() error }); ok { - return cw.CloseWrite() - } - return c.Close() -} - -func (d *AgentDialer) dial(ctx context.Context, network, address string) (net.Conn, error) { - if address == "" { - return nil, fmt.Errorf("sambox: no %s address configured", network) - } - - dial := d.DialContext - if dial == nil { - dial = (&net.Dialer{}).DialContext - } - - conn, err := dial(ctx, network, address) - if err != nil { - return nil, classifyDialError(err) - } - return conn, nil -} - -// classifyDialError maps a dial failure onto the vocabulary the CONNECT layer -// can report, so an agent sees "refused" or "unreachable" rather than a -// generic failure it cannot act on. -func classifyDialError(err error) error { - var dnsErr *net.DNSError - switch { - case errors.Is(err, syscall.ECONNREFUSED): - return fmt.Errorf("%w: %v", ErrConnectionRefused, err) - case errors.As(err, &dnsErr), - errors.Is(err, syscall.EHOSTUNREACH), - errors.Is(err, syscall.ENETUNREACH): - return fmt.Errorf("%w: %v", ErrHostUnreachable, err) - default: - return err - } -} diff --git a/internal/sambox/dial_test.go b/internal/sambox/dial_test.go deleted file mode 100644 index b73eaf99..00000000 --- a/internal/sambox/dial_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "errors" - "net" - "net/http" - "testing" - - "github.com/google/sam/api" -) - -// closedTCPAddr returns an address nothing is listening on. -func closedTCPAddr(t *testing.T) string { - t.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - addr := l.Addr().String() - if err := l.Close(); err != nil { - t.Fatalf("close: %v", err) - } - return addr -} - -func mustEgressPolicy(t *testing.T, allow ...string) *EgressPolicy { - t.Helper() - p, err := NewEgressPolicy(allow) - if err != nil { - t.Fatalf("NewEgressPolicy: %v", err) - } - return p -} - -func TestExternalDestinationRequiresPolicy(t *testing.T) { - d := &AgentDialer{Router: &Router{Egress: mustEgressPolicy(t, "127.0.0.1")}} - - echo := startEcho(t) - host, port, err := net.SplitHostPort(echo) - if err != nil { - t.Fatalf("SplitHostPort: %v", err) - } - dst := Destination{Name: host, Port: atoiPort(t, port)} - - conn, err := d.DialDestination(context.Background(), nil, dst) - if err != nil { - t.Fatalf("allowlisted destination was refused: %v", err) - } - _ = conn.Close() - - denied := Destination{Name: "evil.example", Port: 443, IsName: true} - if _, err := d.DialDestination(context.Background(), nil, denied); !errors.Is(err, ErrNotAllowed) { - t.Errorf("DialDestination(%s) = %v, want ErrNotAllowed", denied, err) - } -} - -// TestRefusedDestinationIsReportedAsRefused pins the error mapping: an agent -// should be able to tell "nothing is listening" from "you may not go there". -func TestRefusedDestinationIsReportedAsRefused(t *testing.T) { - closed := closedTCPAddr(t) - host, port, err := net.SplitHostPort(closed) - if err != nil { - t.Fatalf("SplitHostPort: %v", err) - } - - d := &AgentDialer{Router: &Router{Egress: mustEgressPolicy(t, host)}} - - _, err = d.DialDestination(context.Background(), nil, Destination{Name: host, Port: atoiPort(t, port)}) - if !errors.Is(err, ErrConnectionRefused) { - t.Fatalf("DialDestination to a closed port = %v, want ErrConnectionRefused", err) - } - if status, _ := refusalFor(err); status != http.StatusBadGateway { - t.Errorf("refusal status = %d, want %d", status, http.StatusBadGateway) - } -} - -func TestUnresolvableDestinationIsReportedAsUnreachable(t *testing.T) { - d := &AgentDialer{Router: &Router{Egress: mustEgressPolicy(t, "*.invalid")}} - - _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: "nothing.here.invalid", - Port: 443, - IsName: true, - }) - if !errors.Is(err, ErrHostUnreachable) { - t.Fatalf("DialDestination to an unresolvable name = %v, want ErrHostUnreachable", err) - } -} - -func TestDialerRequiresARouter(t *testing.T) { - var d AgentDialer - if _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: api.MeshEntrypointHost, - Port: 80, - IsName: true, - }); err == nil { - t.Fatal("DialDestination with no router succeeded, want an error") - } -} - -func atoiPort(t *testing.T, port string) uint16 { - t.Helper() - addr, err := net.ResolveTCPAddr("tcp", net.JoinHostPort("127.0.0.1", port)) - if err != nil { - t.Fatalf("ResolveTCPAddr: %v", err) - } - return uint16(addr.Port) -} diff --git a/internal/sambox/entrypoint.go b/internal/sambox/entrypoint.go deleted file mode 100644 index fef3a73f..00000000 --- a/internal/sambox/entrypoint.go +++ /dev/null @@ -1,103 +0,0 @@ -// 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. - -package sambox - -import ( - "net" - "net/http" - "net/http/httputil" - "strings" - - "github.com/google/sam/api" -) - -// The gateway consumes the node; the agent consumes the mesh through the -// gateway. Those are different surfaces and this file is the boundary between -// them. -// -// A sam-node's sidecar API is local and operator-facing: it can register -// services under the node's identity, drive the raw /sam//... egress -// proxy at any peer and service the operator chooses, and read node internals. -// Reaching its Unix socket is itself the credential — withAuth treats arriving -// there as proof of authorization, on the grounds that it is the same bar as -// reading the token file. Piping an agent's bytes to that socket would -// therefore hand every sandbox the node's full local authority, so the -// entrypoint terminates HTTP and forwards only what an agent is supposed to -// have. - -// agentMayReach is the entire surface an agent gets on the node. Inference and -// tools, and nothing else. -// -// Discovery is not on the list even though agents need it: it is already -// available through MCP as find_remote_tools and discover_remote_services, so -// exposing /sam/service/discover as well would widen the surface without adding -// a capability. Serving is not on the list at all — what an agent serves is -// declared by the platform in its bundle and by the operator in the node's -// configuration, and the agent's only part is binding its contracted port. -func agentMayReach(path string) bool { - switch path { - case "/v1/models", "/v1/chat/completions", "/v1/completions": - return true - } - return path == "/mcp" || strings.HasPrefix(path, "/mcp/") -} - -// dialMeshEntrypoint returns a connection serving the agent-facing surface. -func (d *AgentDialer) dialMeshEntrypoint() (net.Conn, error) { - if d.SidecarSocket == "" { - return nil, ErrHostUnreachable - } - return serveOnPipe(d.entrypointHandler()), nil -} - -func (d *AgentDialer) entrypointHandler() http.Handler { - proxy := &httputil.ReverseProxy{ - Rewrite: func(r *httputil.ProxyRequest) { - r.Out.URL.Scheme = "http" - r.Out.URL.Host = sidecarHost - r.Out.Host = sidecarHost - d.assertAgent(r) - }, - Transport: d.sidecarTransport(), - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !agentMayReach(r.URL.Path) { - http.Error(w, "the mesh entrypoint serves /v1 and /mcp only", http.StatusForbidden) - return - } - proxy.ServeHTTP(w, r) - }) -} - -// assertAgent replaces every identity-bearing header with what the gateway -// knows, so an agent cannot claim to be anything by setting them itself. -// -// X-Sam-Biscuit is the mesh datapath credential and X-Sam-Authentication is the -// node's local gate; both are the node's business, not the agent's. X-Sam-Agent -// is the one the gateway does set, and it is always overwritten rather than -// merged: an agent's own value must never survive. -// -// Authorization is deliberately untouched. There it means the destination -// service's credential, which is the agent's to send. -func (d *AgentDialer) assertAgent(r *httputil.ProxyRequest) { - r.Out.Header.Del(api.HeaderSamBiscuit) - r.Out.Header.Del(api.HeaderSamAuthentication) - - r.Out.Header.Del(api.HeaderSamAgent) - if d.AgentID != "" { - r.Out.Header.Set(api.HeaderSamAgent, d.AgentID) - } -} diff --git a/internal/sambox/entrypoint_test.go b/internal/sambox/entrypoint_test.go deleted file mode 100644 index 6b1e0cf5..00000000 --- a/internal/sambox/entrypoint_test.go +++ /dev/null @@ -1,265 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "io" - "net/http" - "testing" - - "github.com/google/sam/api" -) - -type sidecarCall struct { - path string - headers http.Header -} - -// recordingSidecar answers anything and reports what it was asked for, so a -// test can assert both what reached the node and what did not. -func recordingSidecar(t *testing.T) (socket string, calls chan sidecarCall) { - t.Helper() - calls = make(chan sidecarCall, 8) - socket = startFakeSidecar(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case calls <- sidecarCall{path: r.URL.Path, headers: r.Header.Clone()}: - default: - t.Errorf("unexpected extra call to %s", r.URL.Path) - } - _, _ = io.WriteString(w, "ok") - })) - return socket, calls -} - -func entrypointClient(t *testing.T, socket string) *http.Client { - t.Helper() - return entrypointClientForAgent(t, socket, "") -} - -func entrypointClientForAgent(t *testing.T, socket, agentID string) *http.Client { - t.Helper() - boundary := startBoundary(t, &ConnectServer{ - Dialer: &AgentDialer{Router: &Router{}, SidecarSocket: socket, AgentID: agentID}, - }) - return &http.Client{Transport: &http.Transport{DialContext: boundaryDialContext(boundary)}} -} - -// TestAgentIdentityIsAssertedToTheNode covers the half of admission that makes -// an agent visible to mesh policy: the gateway names the principal, because it -// is the only party that knows which agent a flow belongs to. -func TestAgentIdentityIsAssertedToTheNode(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClientForAgent(t, socket, "reviewer-7.prod.acme.example") - - resp, err := client.Get("http://" + api.MeshEntrypointHost + "/v1/models") - if err != nil { - t.Fatalf("Get: %v", err) - } - _ = resp.Body.Close() - - if got := (<-calls).headers.Get(api.HeaderSamAgent); got != "reviewer-7.prod.acme.example" { - t.Errorf("%s = %q, want the agent the gateway serves", api.HeaderSamAgent, got) - } -} - -// TestAgentCannotForgeItsIdentity is the other half. An agent that could set -// the header would be able to borrow any other agent's authority, so the -// gateway overwrites it rather than merging with it. -func TestAgentCannotForgeItsIdentity(t *testing.T) { - t.Run("a different agent", func(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClientForAgent(t, socket, "reviewer-7.prod.acme.example") - - req, err := http.NewRequest(http.MethodGet, "http://"+api.MeshEntrypointHost+"/v1/models", nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) - } - req.Header.Set(api.HeaderSamAgent, "privileged.prod.acme.example") - - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - _ = resp.Body.Close() - - if got := (<-calls).headers.Get(api.HeaderSamAgent); got != "reviewer-7.prod.acme.example" { - t.Errorf("%s = %q, want the forged value replaced", api.HeaderSamAgent, got) - } - }) - - t.Run("any agent at all when the boundary has none", func(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClient(t, socket) - - req, err := http.NewRequest(http.MethodGet, "http://"+api.MeshEntrypointHost+"/v1/models", nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) - } - req.Header.Set(api.HeaderSamAgent, "privileged.prod.acme.example") - - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - _ = resp.Body.Close() - - if got := (<-calls).headers.Get(api.HeaderSamAgent); got != "" { - t.Errorf("%s = %q, want it stripped entirely", api.HeaderSamAgent, got) - } - }) -} - -// TestAgentReachesInferenceAndTools covers what an agent is supposed to have: -// the mesh's inference and tool endpoints, reached by name, through the -// boundary's CONNECT tunnels. -func TestAgentReachesInferenceAndTools(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClient(t, socket) - - for _, path := range []string{"/v1/models", "/v1/chat/completions", "/v1/completions", "/mcp"} { - resp, err := client.Get("http://" + api.MeshEntrypointHost + path) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("GET %s: status %s, want 200", path, resp.Status) - } - if got := (<-calls).path; got != path { - t.Errorf("node saw %q, want %q", got, path) - } - } -} - -// TestAgentCannotReachTheNodeAPI is the separation this boundary exists for. -// The node's sidecar is an operator surface: registering a service would let an -// agent advertise itself into the mesh under the node's identity and choose the -// URL the mesh then routes to, and the raw egress proxy would let it reach any -// peer and service it names. Nothing here may reach the node at all. -func TestAgentCannotReachTheNodeAPI(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClient(t, socket) - - forbidden := []string{ - "/sam/service/register", - "/sam/service/unregister", - "/sam/service/discover", - "/sam/12D3KooWsomepeer/mcp/anything", - "/metrics", - "/healthz", - "/readyz", - "/", - "/v1/embeddings", - "/mcpsomething", - } - - for _, path := range forbidden { - resp, err := client.Get("http://" + api.MeshEntrypointHost + path) - if err != nil { - t.Fatalf("GET %s: %v", path, err) - } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusForbidden { - t.Errorf("GET %s: status %s, want 403", path, resp.Status) - } - } - - select { - case call := <-calls: - t.Fatalf("the node was reached at %q; an agent must not reach it at all", call.path) - default: - } -} - -// TestEntrypointStripsAssertedIdentityHeaders pins that an agent cannot claim -// an identity by setting the headers the node honours. -func TestEntrypointStripsAssertedIdentityHeaders(t *testing.T) { - socket, calls := recordingSidecar(t) - client := entrypointClient(t, socket) - - req, err := http.NewRequest(http.MethodGet, "http://"+api.MeshEntrypointHost+"/v1/models", nil) - if err != nil { - t.Fatalf("NewRequest: %v", err) - } - req.Header.Set(api.HeaderSamBiscuit, "forged-mesh-credential") - req.Header.Set(api.HeaderSamAuthentication, "Bearer forged-node-token") - req.Header.Set("Authorization", "Bearer the-agents-own-backend-credential") - - resp, err := client.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - _ = resp.Body.Close() - - call := <-calls - if got := call.headers.Get(api.HeaderSamBiscuit); got != "" { - t.Errorf("%s reached the node as %q, want it stripped", api.HeaderSamBiscuit, got) - } - if got := call.headers.Get(api.HeaderSamAuthentication); got != "" { - t.Errorf("%s reached the node as %q, want it stripped", api.HeaderSamAuthentication, got) - } - // Authorization means the destination service's own credential, so it is - // the agent's to send and must survive. - if got := call.headers.Get("Authorization"); got != "Bearer the-agents-own-backend-credential" { - t.Errorf("Authorization = %q, want it forwarded untouched", got) - } -} - -// TestEntrypointIgnoresTheRequestedPort pins that the entrypoint is a name for -// a surface, not for an address. -func TestEntrypointIgnoresTheRequestedPort(t *testing.T) { - socket, _ := recordingSidecar(t) - d := &AgentDialer{Router: &Router{}, SidecarSocket: socket} - - for _, port := range []uint16{80, 443, 8080} { - conn, err := d.DialDestination(context.Background(), nil, Destination{ - Name: api.MeshEntrypointHost, - Port: port, - IsName: true, - }) - if err != nil { - t.Fatalf("port %d: %v", port, err) - } - _ = conn.Close() - } -} - -func TestEntrypointRequiresASidecarSocket(t *testing.T) { - d := &AgentDialer{Router: &Router{}} - if _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: api.MeshEntrypointHost, - Port: 80, - IsName: true, - }); err == nil { - t.Fatal("DialDestination with no sidecar socket succeeded, want an error") - } -} - -func TestAgentMayReach(t *testing.T) { - allowed := []string{"/v1/models", "/v1/chat/completions", "/v1/completions", "/mcp", "/mcp/session"} - for _, path := range allowed { - if !agentMayReach(path) { - t.Errorf("agentMayReach(%q) = false, want true", path) - } - } - - denied := []string{"", "/", "/v1", "/v1/", "/v1/models/extra", "/mcpsomething", "/sam/service/register", "/metrics"} - for _, path := range denied { - if agentMayReach(path) { - t.Errorf("agentMayReach(%q) = true, want false", path) - } - } -} diff --git a/internal/sambox/ingress.go b/internal/sambox/ingress.go deleted file mode 100644 index 46f9c86e..00000000 --- a/internal/sambox/ingress.go +++ /dev/null @@ -1,255 +0,0 @@ -// 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. - -package sambox - -import ( - "bufio" - "context" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strconv" - "strings" - "sync" - "time" -) - -// An agent that serves never says so to anyone. The node's exposed services -// are declared in the node's own configuration, where the operator names this -// gateway's ingress address as the backend; the bundle contracts which names -// the agent serves and on which sandbox port (like $PORT on a serverless -// runtime). Nothing at runtime can add a name to the mesh, and there is no -// agent-facing surface at all: the agent just binds its contracted port. -// -// Readiness is implicit. Until the agent listens, forwarding fails at the -// sandbox's reverse channel, so the node's backend probe fails and the name -// is withheld from discovery; when the agent binds the port everything -// converges, and when the sandbox goes away the probe fails again. Dynamic -// agent behaviour beyond that -- capabilities, negotiation, reconfiguration -- -// belongs to the protocol served over the name (A2A, MCP), not to the mesh. - -// IngressManager forwards what the mesh delivers to the ports the platform -// contracted the agent to serve. -type IngressManager struct { - // ListenAddr is where this gateway's ingress listens, e.g. - // "127.0.0.1:7080". It must be stable: the node's configuration names it - // as the declared services' backend. Empty picks an ephemeral port, - // which only tests can meaningfully consume via Addr. - ListenAddr string - - // Serves is the bundle's contract: the agent's a2a service name and the - // sandbox port it binds. An agent serves at most itself; tools and models - // are operator workloads, not agent ingress. - Serves BundleServes - - // AgentSocket is the sandbox's reverse channel: a Unix socket nano-init - // listens on from inside the sandbox. It is how an isolated agent is - // reached at all, because every sandbox has a network namespace of its own - // and the gateway's 127.0.0.1 is therefore not the agent's. A pathname - // socket crosses that boundary for the same reason the egress one does: it - // is a filesystem object, and network namespaces do not apply to it. - // - // Empty means the agent shares this process's network namespace and can be - // dialled directly, which is true of no sandboxed profile. - AgentSocket string - - // AgentAddr resolves where the agent listens inside its sandbox. Setting it - // overrides both of the above, which is how tests point the forwarder at a - // server of their own. - AgentAddr func(port int) string - - mu sync.Mutex - listener net.Listener - routes map[string]int // service name -> port inside the sandbox -} - -// Start validates that the sandbox can be reached, builds the routes the -// bundle contracts, and serves the ingress. It returns the bound address, -// which is what the node's configuration must name as the services' backend. -func (m *IngressManager) Start() (string, error) { - if err := m.reachable(); err != nil { - return "", err - } - - m.mu.Lock() - defer m.mu.Unlock() - - if m.listener != nil { - return m.listener.Addr().String(), nil - } - m.routes = map[string]int{m.Serves.Name: m.Serves.Port} - log.Printf("sambox: serving a2a://%s from the sandbox's port %d", m.Serves.Name, m.Serves.Port) - - addr := m.ListenAddr - if addr == "" { - addr = "127.0.0.1:0" - } - listener, err := net.Listen("tcp", addr) - if err != nil { - return "", err - } - m.listener = listener - - server := &http.Server{Handler: m.forwarder(), ReadHeaderTimeout: 10 * time.Second} - go func() { - _ = server.Serve(listener) - }() - return listener.Addr().String(), nil -} - -// forwarder carries what the node delivers into the sandbox, stripping the -// service name the gateway added so the agent sees the path it published. -func (m *IngressManager) forwarder() http.Handler { - proxy := &httputil.ReverseProxy{ - Transport: m.AgentTransport(), - Rewrite: func(r *httputil.ProxyRequest) { - name, rest := splitServicePath(r.In.URL.Path) - - m.mu.Lock() - port, known := m.routes[name] - m.mu.Unlock() - if !known { - // Nothing to route to; the proxy reports a failure rather than - // dialling something arbitrary. - r.Out.URL = &url.URL{Scheme: "http", Host: "ingress.invalid"} - return - } - - r.Out.URL.Scheme = "http" - r.Out.URL.Host = m.agentAddr(port) - r.Out.Host = r.Out.URL.Host - r.Out.URL.Path = rest - r.Out.URL.RawPath = "" - }, - } - return proxy -} - -// agentAddr names where the agent is, for a transport that knows how to get -// there. The port is the agent's own choice, so this must never become an -// address in this process's network namespace: see reachable. -func (m *IngressManager) agentAddr(port int) string { - if m.AgentAddr != nil { - return m.AgentAddr(port) - } - return net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) -} - -// reachable reports whether this manager can deliver into the sandbox at all. -// -// There used to be a fallback here: with no reverse channel, dial -// 127.0.0.1: and hope the agent shares this network namespace. That is a -// hole rather than a degraded mode. The port is chosen by the agent, and this -// process's loopback is the pod's -- where sam-node's API, other sidecars and -// every other boundary are listening. An agent could therefore announce a -// service whose backend is the node that vouches for it, and the mesh would -// route to it. -// -// So an agent that may serve needs a channel into its sandbox, and without one -// nothing is registered. -func (m *IngressManager) reachable() error { - if m.AgentSocket != "" || m.AgentAddr != nil { - return nil - } - return fmt.Errorf("no way into the sandbox: set --agent-ingress-socket to the path " + - "nano-init --ingress-socket serves, because delivering to an address in this " + - "process's network namespace would reach the gateway's neighbours rather than the agent") -} - -// AgentTransport reaches the sandbox over its reverse channel when there is -// one, and returns nil when the agent can be dialled directly. -// -// The address the forwarder writes is still 127.0.0.1:, because that is -// what the port means where it is going. Only the dialling changes: the port is -// carried in the handshake and the connection is made by the process inside the -// sandbox, which is the one that can. -func (m *IngressManager) AgentTransport() http.RoundTripper { - if m.AgentSocket == "" { - return nil // the default transport dials the address directly - } - socket := m.AgentSocket - return &http.Transport{ - DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { - _, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, fmt.Errorf("ingress target %q: %w", addr, err) - } - return dialSandbox(ctx, socket, port) - }, - } -} - -// dialSandbox opens one connection through the sandbox's reverse channel. -// -// The handshake is Firecracker's -- "CONNECT ", then "OK" -- so a microVM -// can offer the same protocol over vsock and nothing here has to know which -// kind of sandbox it is talking to. -func dialSandbox(ctx context.Context, socket, port string) (net.Conn, error) { - var d net.Dialer - conn, err := d.DialContext(ctx, "unix", socket) - if err != nil { - return nil, fmt.Errorf("reach the sandbox's ingress socket %s: %w", socket, err) - } - if deadline, ok := ctx.Deadline(); ok { - _ = conn.SetDeadline(deadline) - } - if _, err := fmt.Fprintf(conn, "CONNECT %s\n", port); err != nil { - _ = conn.Close() - return nil, fmt.Errorf("ask the sandbox for port %s: %w", port, err) - } - reply, err := bufio.NewReader(io.LimitReader(conn, 128)).ReadString('\n') - if err != nil { - _ = conn.Close() - return nil, fmt.Errorf("read the sandbox's answer for port %s: %w", port, err) - } - if strings.TrimSpace(reply) != "OK" { - _ = conn.Close() - return nil, fmt.Errorf("the sandbox refused port %s: %s", port, strings.TrimSpace(reply)) - } - _ = conn.SetDeadline(time.Time{}) - return conn, nil -} - -// Close stops serving, so a detached sandbox stops being routed to: the -// node's backend probe starts failing and withholds the name from discovery. -func (m *IngressManager) Close() { - m.mu.Lock() - listener := m.listener - m.listener = nil - m.routes = nil - m.mu.Unlock() - - if listener != nil { - _ = listener.Close() - } -} - -// splitServicePath separates the leading service name from the rest of the path. -func splitServicePath(path string) (name, rest string) { - trimmed := path - if len(trimmed) > 0 && trimmed[0] == '/' { - trimmed = trimmed[1:] - } - for i := 0; i < len(trimmed); i++ { - if trimmed[i] == '/' { - return trimmed[:i], trimmed[i:] - } - } - return trimmed, "/" -} diff --git a/internal/sambox/ingress_test.go b/internal/sambox/ingress_test.go deleted file mode 100644 index 674b8eb6..00000000 --- a/internal/sambox/ingress_test.go +++ /dev/null @@ -1,152 +0,0 @@ -// 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. - -package sambox - -import ( - "io" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" -) - -// TestIngressForwardsIntoTheSandbox: what the node delivers reaches the agent -// on its bundle-contracted port, with the service name the operator's config -// added stripped back off so the agent sees the path it serves. The agent did -// nothing to make this happen but listen. -func TestIngressForwardsIntoTheSandbox(t *testing.T) { - paths := make(chan string, 1) - agent := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - paths <- r.URL.Path - _, _ = io.WriteString(w, "served by the agent") - })) - defer agent.Close() - - manager := &IngressManager{ - Serves: BundleServes{Name: "code-reviewer", Port: 8080}, - // The sandbox here is an ordinary server, so the contracted port is - // reached at the test server's address. - AgentAddr: func(int) string { return strings.TrimPrefix(agent.URL, "http://") }, - } - t.Cleanup(manager.Close) - - addr, err := manager.Start() - if err != nil { - t.Fatalf("Start: %v", err) - } - - resp, err := http.Get("http://" + addr + "/code-reviewer/review") - if err != nil { - t.Fatalf("Get: %v", err) - } - defer func() { _ = resp.Body.Close() }() - - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if string(body) != "served by the agent" { - t.Errorf("body = %q", body) - } - if got := <-paths; got != "/review" { - t.Errorf("the agent saw %q, want the service name stripped", got) - } -} - -// TestIngressRefusesToRouteAnUngrantedName is the property that keeps an -// agent from serving under somebody else's name: only the names the bundle -// contracts have routes, and nothing at runtime can add one. -func TestIngressRefusesToRouteAnUngrantedName(t *testing.T) { - manager := &IngressManager{ - Serves: BundleServes{Name: "code-reviewer", Port: 8080}, - AgentSocket: filepath.Join(t.TempDir(), "ingress.sock"), - } - t.Cleanup(manager.Close) - - addr, err := manager.Start() - if err != nil { - t.Fatalf("Start: %v", err) - } - - resp, err := http.Get("http://" + addr + "/never-granted/anything") - if err != nil { - t.Fatalf("Get: %v", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode == http.StatusOK { - t.Error("the gateway routed a name the bundle never granted") - } -} - -// TestIngressWithNoWayInRefusesRatherThanDiallingItsOwnNamespace is a -// vulnerability regression. -// -// There used to be a fallback: with no reverse channel, deliver to -// 127.0.0.1:. That address is in the gateway's network namespace, which -// in a pod is the pod's -- sam-node's API, the other sidecars, every other -// boundary. So a bundle-contracted port would be delivered to the gateway's -// neighbours rather than the agent. Without a way into the sandbox the -// gateway must not serve at all. -func TestIngressWithNoWayInRefusesRatherThanDiallingItsOwnNamespace(t *testing.T) { - manager := &IngressManager{ - Serves: BundleServes{Name: "code-reviewer", Port: 8080}, - } - t.Cleanup(manager.Close) - - if _, err := manager.Start(); err == nil { - t.Fatal("the gateway agreed to serve a sandbox it has no way into") - } -} - -// TestIngressStopsAnsweringOnClose: a detached sandbox must stop being routed -// to. With the service declared on the node, that means the ingress goes away -// and the node's backend probe withholds the name from discovery. -func TestIngressStopsAnsweringOnClose(t *testing.T) { - manager := &IngressManager{ - Serves: BundleServes{Name: "code-reviewer", Port: 8080}, - AgentSocket: filepath.Join(t.TempDir(), "ingress.sock"), - } - addr, err := manager.Start() - if err != nil { - t.Fatalf("Start: %v", err) - } - - manager.Close() - - if _, err := http.Get("http://" + addr + "/code-reviewer/review"); err == nil { - t.Error("the ingress still answers after Close") - } -} - -func TestSplitServicePath(t *testing.T) { - tests := []struct { - path string - wantName string - wantRest string - }{ - {"/code-reviewer/review", "code-reviewer", "/review"}, - {"/code-reviewer", "code-reviewer", "/"}, - {"/code-reviewer/", "code-reviewer", "/"}, - {"/code-reviewer/a/b", "code-reviewer", "/a/b"}, - {"/", "", "/"}, - } - - for _, tc := range tests { - t.Run(tc.path, func(t *testing.T) { - name, rest := splitServicePath(tc.path) - if name != tc.wantName || rest != tc.wantRest { - t.Errorf("splitServicePath(%q) = %q, %q; want %q, %q", tc.path, name, rest, tc.wantName, tc.wantRest) - } - }) - } -} diff --git a/internal/sambox/listen.go b/internal/sambox/listen.go deleted file mode 100644 index 60fd765a..00000000 --- a/internal/sambox/listen.go +++ /dev/null @@ -1,69 +0,0 @@ -// 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. - -package sambox - -import ( - "fmt" - "net" - "os" - "path/filepath" - "time" -) - -// maxUnixPathLen is the kernel's sun_path budget. Overflowing it only yields -// "invalid argument" from bind(2), which is not a useful thing to hand an -// operator. -const maxUnixPathLen = 104 - -// ListenSandboxSocket binds the sandbox-facing socket. A socket left behind by -// a crashed gateway is replaced; one a live gateway is still answering on is -// not. -// -// The socket is created 0600. For a microVM that is exactly right, since -// firecracker connects to it as the same user. For a container whose sandbox -// runs as a different uid, the platform has to align ownership when it creates -// the sandbox — which is where per-agent sockets will be created once admission -// exists, and the only place that knows which uid to use. -func ListenSandboxSocket(path string) (net.Listener, error) { - if len(path) >= maxUnixPathLen { - return nil, fmt.Errorf("socket path %q is too long (%d bytes, the kernel allows %d)", path, len(path), maxUnixPathLen-1) - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return nil, fmt.Errorf("creating the socket directory: %w", err) - } - - if info, err := os.Stat(path); err == nil { - if info.Mode()&os.ModeSocket == 0 { - return nil, fmt.Errorf("%s already exists and is not a socket", path) - } - if conn, err := net.DialTimeout("unix", path, time.Second); err == nil { - _ = conn.Close() - return nil, fmt.Errorf("another gateway is already listening on %s", path) - } - if err := os.Remove(path); err != nil { - return nil, fmt.Errorf("removing the stale socket %s: %w", path, err) - } - } - - listener, err := net.Listen("unix", path) - if err != nil { - return nil, fmt.Errorf("listening on %s: %w", path, err) - } - if err := os.Chmod(path, 0600); err != nil { - _ = listener.Close() - return nil, fmt.Errorf("restricting access to %s: %w", path, err) - } - return listener, nil -} diff --git a/internal/sambox/listen_test.go b/internal/sambox/listen_test.go deleted file mode 100644 index 809e32be..00000000 --- a/internal/sambox/listen_test.go +++ /dev/null @@ -1,126 +0,0 @@ -// 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. - -package sambox - -import ( - "net" - "os" - "path/filepath" - "strings" - "testing" -) - -func tempSocketPath(t *testing.T, name string) string { - t.Helper() - dir, err := os.MkdirTemp("", "sambox") - if err != nil { - t.Fatalf("MkdirTemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - return filepath.Join(dir, name) -} - -func TestListenSandboxSocketIsPrivate(t *testing.T) { - path := tempSocketPath(t, "agent.sock") - - l, err := ListenSandboxSocket(path) - if err != nil { - t.Fatalf("ListenSandboxSocket: %v", err) - } - defer func() { _ = l.Close() }() - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat: %v", err) - } - if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("socket mode = %o, want 600", perm) - } -} - -// TestListenSandboxSocketReplacesAStaleSocket covers the restart case: a -// gateway that crashed leaves the file behind, and refusing to start would turn -// one crash into a permanent outage. -func TestListenSandboxSocketReplacesAStaleSocket(t *testing.T) { - path := tempSocketPath(t, "agent.sock") - - first, err := ListenSandboxSocket(path) - if err != nil { - t.Fatalf("first listen: %v", err) - } - if err := first.Close(); err != nil { - t.Fatalf("close: %v", err) - } - // Closing a Unix listener removes the file, so put it back to model the - // crash that never got to clean up. - stale, err := net.Listen("unix", path) - if err != nil { - t.Fatalf("recreate socket: %v", err) - } - stale.(*net.UnixListener).SetUnlinkOnClose(false) - if err := stale.Close(); err != nil { - t.Fatalf("close stale: %v", err) - } - - second, err := ListenSandboxSocket(path) - if err != nil { - t.Fatalf("ListenSandboxSocket over a stale socket: %v", err) - } - _ = second.Close() -} - -// TestListenSandboxSocketRefusesALiveGateway is the other half: replacing a -// socket somebody is still answering on would silently steal their agents. -func TestListenSandboxSocketRefusesALiveGateway(t *testing.T) { - path := tempSocketPath(t, "agent.sock") - - live, err := ListenSandboxSocket(path) - if err != nil { - t.Fatalf("first listen: %v", err) - } - defer func() { _ = live.Close() }() - go func() { - for { - conn, err := live.Accept() - if err != nil { - return - } - _ = conn.Close() - } - }() - - if _, err := ListenSandboxSocket(path); err == nil { - t.Fatal("ListenSandboxSocket replaced a live gateway, want an error") - } -} - -func TestListenSandboxSocketRejectsBadPaths(t *testing.T) { - t.Run("not a socket", func(t *testing.T) { - path := tempSocketPath(t, "agent.sock") - if err := os.WriteFile(path, []byte("not a socket"), 0600); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if _, err := ListenSandboxSocket(path); err == nil { - t.Fatal("ListenSandboxSocket accepted a regular file, want an error") - } - }) - - t.Run("too long for the kernel", func(t *testing.T) { - path := tempSocketPath(t, strings.Repeat("a", 120)+".sock") - if _, err := ListenSandboxSocket(path); err == nil { - t.Fatal("ListenSandboxSocket accepted an over-long path, want an error") - } - }) -} diff --git a/internal/sambox/mesh.go b/internal/sambox/mesh.go deleted file mode 100644 index 593d659f..00000000 --- a/internal/sambox/mesh.go +++ /dev/null @@ -1,204 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "sync" - "time" - - "github.com/google/sam/api" -) - -// Reaching a named mesh service is the one destination that is not a byte pipe. -// The agent speaks HTTP to "openrouter.inference.sam.alt", while the sidecar -// routes by path, so somebody has to discover a provider and rewrite the -// request onto /sam///. That happens here, on an in-process -// HTTP server whose other end is handed back to the CONNECT layer as an -// ordinary connection. - -const ( - // maxDiscoverBody bounds the discovery response. It is small and local, but - // it is still parsed input and gets a limit like any other. - maxDiscoverBody = 1 << 20 - - // sidecarHost is a placeholder authority: the transport dials the Unix - // socket, so the host in the URL is never resolved. - sidecarHost = "sam-node" -) - -// dialMeshService resolves the service to a provider and returns a connection -// that carries the agent's HTTP through to it. -func (d *AgentDialer) dialMeshService(ctx context.Context, route Route) (net.Conn, error) { - if d.SidecarSocket == "" { - return nil, fmt.Errorf("sambox: no sidecar socket configured") - } - - svcType, svcName := api.ParseServiceTarget(route.ServiceURI) - peerID, err := d.discoverProvider(ctx, svcType, svcName) - if err != nil { - return nil, err - } - - transport := d.sidecarTransport() - prefix := "/sam/" + peerID + "/" + svcType + "/" + svcName - - proxy := &httputil.ReverseProxy{ - Rewrite: func(r *httputil.ProxyRequest) { - r.Out.URL.Scheme = "http" - r.Out.URL.Host = sidecarHost - r.Out.Host = sidecarHost - r.Out.URL.Path = prefix + r.In.URL.Path - r.Out.URL.RawPath = "" - d.assertAgent(r) - }, - Transport: transport, - } - - return serveOnPipe(proxy), nil -} - -// serveOnPipe runs h on one end of an in-memory connection and hands back the -// other, so an HTTP handler can be given to the CONNECT layer as an ordinary -// connection. -func serveOnPipe(h http.Handler) net.Conn { - agentSide, boundarySide := net.Pipe() - ln := newSingleConnListener(boundarySide) - server := &http.Server{ - Handler: h, - // Mirrors the sidecar: bound header reads, but let bodies and responses - // stream, since inference completions and MCP sessions legitimately do. - ReadHeaderTimeout: 10 * time.Second, - IdleTimeout: 120 * time.Second, - } - go func() { - _ = server.Serve(ln) - }() - return agentSide -} - -// discoverProvider asks the sidecar which peers serve the requested service. -// A well-formed name with no provider is unreachable rather than forbidden: -// unlike a malformed mesh name, it tells a sandbox nothing it could not already -// learn from the tool catalog it is allowed to read. -func (d *AgentDialer) discoverProvider(ctx context.Context, svcType, svcName string) (string, error) { - endpoint := (&url.URL{ - Scheme: "http", - Host: sidecarHost, - Path: "/sam/service/discover", - RawQuery: url.Values{"type": {svcType}, "name": {svcName}}.Encode(), - }).String() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return "", err - } - - // No credential: reaching the sidecar's Unix socket is itself the proof of - // authorization, which is why sam-box holds no sidecar token. - resp, err := (&http.Client{Transport: d.sidecarTransport()}).Do(req) - if err != nil { - return "", fmt.Errorf("%w: discovery failed: %v", ErrHostUnreachable, err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("%w: discovery returned %s", ErrHostUnreachable, resp.Status) - } - - var providers []*api.DiscoveredProvider - if err := json.NewDecoder(io.LimitReader(resp.Body, maxDiscoverBody)).Decode(&providers); err != nil { - return "", fmt.Errorf("%w: malformed discovery response: %v", ErrHostUnreachable, err) - } - for _, p := range providers { - if p.GetPeerId() != "" { - // The sidecar already scores and orders providers; taking the first - // keeps that decision in one place. - return p.GetPeerId(), nil - } - } - return "", fmt.Errorf("%w: no provider for %s://%s", ErrHostUnreachable, svcType, svcName) -} - -func (d *AgentDialer) sidecarTransport() http.RoundTripper { - return sidecarTransport(d.SidecarSocket) -} - -// sidecarTransport dials the node's API socket whatever host a URL names, since -// the host in these URLs is a placeholder and never resolved. -func sidecarTransport(socket string) http.RoundTripper { - return &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socket) - }, - } -} - -// singleConnListener hands one already-established connection to an -// http.Server and then blocks until Close, so the server lives exactly as long -// as the agent's connection does. -type singleConnListener struct { - conn net.Conn - - accept sync.Once - closing sync.Once - closed chan struct{} -} - -// closeNotifyConn closes the listener when the underlying connection is closed, -// so http.Server.Serve unblocks from Accept instead of leaking a goroutine. -type closeNotifyConn struct { - net.Conn - fn func() -} - -func (c *closeNotifyConn) Close() error { - err := c.Conn.Close() - c.fn() - return err -} - -func newSingleConnListener(conn net.Conn) *singleConnListener { - l := &singleConnListener{closed: make(chan struct{})} - l.conn = &closeNotifyConn{ - Conn: conn, - fn: func() { _ = l.Close() }, - } - return l -} - -func (l *singleConnListener) Accept() (net.Conn, error) { - var conn net.Conn - l.accept.Do(func() { conn = l.conn }) - if conn != nil { - return conn, nil - } - <-l.closed - return nil, net.ErrClosed -} - -func (l *singleConnListener) Close() error { - l.closing.Do(func() { close(l.closed) }) - return nil -} - -func (l *singleConnListener) Addr() net.Addr { return l.conn.LocalAddr() } diff --git a/internal/sambox/mesh_test.go b/internal/sambox/mesh_test.go deleted file mode 100644 index 371e3836..00000000 --- a/internal/sambox/mesh_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "encoding/json" - "errors" - "io" - "net" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - "github.com/google/sam/api" -) - -// startFakeSidecar serves the two endpoints sam-box uses on a Unix socket: -// service discovery, and the egress proxy path it rewrites onto. -func startFakeSidecar(t *testing.T, h http.Handler) string { - t.Helper() - - dir, err := os.MkdirTemp("", "sambox") - if err != nil { - t.Fatalf("MkdirTemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - path := filepath.Join(dir, "sidecar.sock") - l, err := net.Listen("unix", path) - if err != nil { - t.Fatalf("listen: %v", err) - } - - srv := httptest.NewUnstartedServer(h) - _ = srv.Listener.Close() - srv.Listener = l - srv.Start() - t.Cleanup(srv.Close) - - return path -} - -func discoverHandler(t *testing.T, peerID string, seen *chan string) http.Handler { - t.Helper() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/sam/service/discover": - providers := []*api.DiscoveredProvider{} - if peerID != "" { - providers = append(providers, &api.DiscoveredProvider{ - PeerId: peerID, - SrvName: r.URL.Query().Get("name"), - }) - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(providers); err != nil { - t.Errorf("encode providers: %v", err) - } - default: - if seen != nil { - select { - case *seen <- r.URL.Path: - default: - } - } - _, _ = io.WriteString(w, "reached") - } - }) -} - -// clientOver speaks HTTP over an already-established connection, the way an -// agent's HTTP client speaks over the tunnel CONNECT handed it. -func clientOver(conn net.Conn) *http.Client { - return &http.Client{ - Transport: &http.Transport{ - DialContext: func(context.Context, string, string) (net.Conn, error) { return conn, nil }, - }, - } -} - -// TestMeshServiceRequestIsRewrittenOntoTheSidecarPath is the point of this -// path: a name resolves to a provider, and the agent's request comes out on -// /sam//// without the agent knowing any of it. -func TestMeshServiceRequestIsRewrittenOntoTheSidecarPath(t *testing.T) { - seen := make(chan string, 1) - socket := startFakeSidecar(t, discoverHandler(t, "12D3KooWtestpeer", &seen)) - - d := &AgentDialer{Router: &Router{}, SidecarSocket: socket} - conn, err := d.DialDestination(context.Background(), nil, Destination{ - Name: "openrouter.inference.sam.alt", - Port: 80, - IsName: true, - }) - if err != nil { - t.Fatalf("DialDestination: %v", err) - } - defer func() { _ = conn.Close() }() - - resp, err := clientOver(conn).Get("http://openrouter.inference.sam.alt/v1/models") - if err != nil { - t.Fatalf("Get: %v", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %s, want 200", resp.Status) - } - - got := <-seen - if want := "/sam/12D3KooWtestpeer/inference/openrouter/v1/models"; got != want { - t.Errorf("sidecar saw %q, want %q", got, want) - } -} - -func TestMeshServiceWithNoProviderIsUnreachable(t *testing.T) { - socket := startFakeSidecar(t, discoverHandler(t, "", nil)) - - d := &AgentDialer{Router: &Router{}, SidecarSocket: socket} - _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: "missing.mcp.sam.alt", - Port: 80, - IsName: true, - }) - if !errors.Is(err, ErrHostUnreachable) { - t.Fatalf("DialDestination = %v, want ErrHostUnreachable", err) - } - if status, _ := refusalFor(err); status != http.StatusBadGateway { - t.Errorf("refusal status = %d, want %d", status, http.StatusBadGateway) - } -} - -// TestMalformedMeshNameIsDeniedNotReportedMissing keeps the two failures -// distinct: a name that cannot be a service is a policy denial, so the boundary -// does not confirm what does or does not exist in the mesh. -func TestMalformedMeshNameIsDeniedNotReportedMissing(t *testing.T) { - socket := startFakeSidecar(t, discoverHandler(t, "12D3KooWtestpeer", nil)) - - d := &AgentDialer{Router: &Router{}, SidecarSocket: socket} - _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: "whatever.sam.alt", - Port: 80, - IsName: true, - }) - if !errors.Is(err, ErrNotAllowed) { - t.Fatalf("DialDestination = %v, want ErrNotAllowed", err) - } -} - -func TestMeshServiceRequiresASidecarSocket(t *testing.T) { - d := &AgentDialer{Router: &Router{}} - _, err := d.DialDestination(context.Background(), nil, Destination{ - Name: "openrouter.inference.sam.alt", - Port: 80, - IsName: true, - }) - if err == nil { - t.Fatal("DialDestination with no sidecar socket succeeded, want an error") - } -} - -func TestUnreachableSidecarIsReportedAsUnreachable(t *testing.T) { - dir, err := os.MkdirTemp("", "sambox") - if err != nil { - t.Fatalf("MkdirTemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - - d := &AgentDialer{Router: &Router{}, SidecarSocket: filepath.Join(dir, "absent.sock")} - _, err = d.DialDestination(context.Background(), nil, Destination{ - Name: "openrouter.inference.sam.alt", - Port: 80, - IsName: true, - }) - if !errors.Is(err, ErrHostUnreachable) { - t.Fatalf("DialDestination = %v, want ErrHostUnreachable", err) - } -} - -// TestSingleConnListenerServeExitsOnConnClose verifies that after the one -// connection is finished and closed, http.Server.Serve returns instead of -// blocking forever on a second Accept (goroutine leak in serveOnPipe). -func TestSingleConnListenerServeExitsOnConnClose(t *testing.T) { - agentSide, boundarySide := net.Pipe() - ln := newSingleConnListener(boundarySide) - srv := &http.Server{ - Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, "ok") - }), - ReadHeaderTimeout: 10 * time.Second, - } - - exited := make(chan struct{}) - go func() { - _ = srv.Serve(ln) - close(exited) - }() - - resp, err := clientOver(agentSide).Get("http://mesh.example/") - if err != nil { - t.Fatalf("Get: %v", err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - _ = agentSide.Close() - - select { - case <-exited: - case <-time.After(2 * time.Second): - t.Fatal("http.Server.Serve did not return after pipe close (Accept leak)") - } -} diff --git a/internal/sambox/metrics.go b/internal/sambox/metrics.go deleted file mode 100644 index 7a07d6eb..00000000 --- a/internal/sambox/metrics.go +++ /dev/null @@ -1,90 +0,0 @@ -// 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. - -package sambox - -import ( - "errors" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -// The boundary is the one place that sees every flow an agent opens, so it is -// the only honest place to measure what the boundary costs and what it refused. -// Labels stay closed vocabularies: a destination name is agent-controlled, and -// putting it in a label would let a sandbox grow the metric space without bound. - -// flowSetupBuckets resolve from a sidecar hop on a Unix socket (tens of -// microseconds) up to a mesh dial that crosses the DHT (seconds). The default -// buckets start at 5ms, which is above the median this measures. -var flowSetupBuckets = []float64{ - 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, -} - -var ( - flowsTotal = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "sam_box_flows_total", - Help: "Flows a sandbox asked the boundary to open, by route class and outcome", - }, - []string{"route", "outcome"}, - ) - - flowSetupSeconds = promauto.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "sam_box_flow_setup_seconds", - Help: "Time from an admitted CONNECT to a usable destination connection", - Buckets: flowSetupBuckets, - }, - []string{"route"}, - ) - - flowsActive = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "sam_box_flows_active", - Help: "Flows currently relaying through the boundary", - }, - ) -) - -// routeUnresolved labels a flow the router refused before it could be -// classified, so a denial is never miscounted against a real route. -const routeUnresolved = "unresolved" - -// outcomeFor maps a dial result onto the closed vocabulary the counters use. -func outcomeFor(err error) string { - switch { - case err == nil: - return "allowed" - case errors.Is(err, ErrNotAllowed): - return "denied" - case errors.Is(err, ErrHostUnreachable): - return "unreachable" - case errors.Is(err, ErrConnectionRefused): - return "refused" - default: - return "error" - } -} - -// recordFlow accounts one attempt to open a destination. setup is only -// meaningful when the attempt succeeded, so it is only observed then. -func recordFlow(route string, setup time.Duration, err error) { - flowsTotal.WithLabelValues(route, outcomeFor(err)).Inc() - if err == nil { - flowSetupSeconds.WithLabelValues(route).Observe(setup.Seconds()) - } -} diff --git a/internal/sambox/metrics_server.go b/internal/sambox/metrics_server.go deleted file mode 100644 index 75264679..00000000 --- a/internal/sambox/metrics_server.go +++ /dev/null @@ -1,60 +0,0 @@ -// 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. - -package sambox - -import ( - "context" - "errors" - "net" - "net/http" - "time" - - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// ServeMetrics exposes this boundary's counters on addr until ctx ends. -// -// It is off unless an operator asks for it. The boundary sits between a -// sandbox and the mesh, so any listener it opens is one more thing reachable -// from wherever addr is bound; an experiment wants the numbers, a production -// sandbox usually does not. Nothing here is authenticated, which is why the -// caller has to name the address rather than get one by default. -func ServeMetrics(ctx context.Context, addr string) (*http.Server, error) { - if addr == "" { - return nil, errors.New("sambox: no metrics address configured") - } - - listener, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - server := &http.Server{ - Handler: mux, - ReadHeaderTimeout: 10 * time.Second, - } - - go func() { - <-ctx.Done() - _ = server.Close() - }() - go func() { - _ = server.Serve(listener) - }() - - return server, nil -} diff --git a/internal/sambox/metrics_test.go b/internal/sambox/metrics_test.go deleted file mode 100644 index 93ce7a76..00000000 --- a/internal/sambox/metrics_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// 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. - -package sambox - -import ( - "errors" - "fmt" - "io" - "net" - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" -) - -func TestOutcomeForNamesEveryDenialReasonSeparately(t *testing.T) { - // A denial and an unreachable host mean opposite things about a - // deployment: one is policy working, the other is the mesh failing. - // Collapsing them would make the counters unusable as evidence. - cases := []struct { - err error - want string - }{ - {nil, "allowed"}, - {fmt.Errorf("wrapped: %w", ErrNotAllowed), "denied"}, - {fmt.Errorf("wrapped: %w", ErrHostUnreachable), "unreachable"}, - {fmt.Errorf("wrapped: %w", ErrConnectionRefused), "refused"}, - {errors.New("something else"), "error"}, - } - for _, tc := range cases { - if got := outcomeFor(tc.err); got != tc.want { - t.Errorf("outcomeFor(%v) = %q, want %q", tc.err, got, tc.want) - } - } -} - -func TestRouteKindStringsAreStableLabels(t *testing.T) { - // These strings are metric label values, so renaming one silently breaks - // every dashboard and every recorded experiment that used it. - cases := map[RouteKind]string{ - RouteMeshEntrypoint: "mesh-entrypoint", - RouteMeshService: "mesh-service", - RouteExternal: "external", - } - for kind, want := range cases { - if got := kind.String(); got != want { - t.Errorf("RouteKind(%d).String() = %q, want %q", int(kind), got, want) - } - } -} - -func TestCountedConnPropagatesHalfClose(t *testing.T) { - // The relay half-closes to signal EOF upstream. If the wrapper swallows - // CloseWrite, a peer waiting on EOF hangs until a timeout instead. - client, server := net.Pipe() - defer func() { _ = client.Close() }() - defer func() { _ = server.Close() }() - - var closed bool - c := &countedConn{Conn: halfCloser{Conn: client, onCloseWrite: func() { closed = true }}} - if err := c.CloseWrite(); err != nil { - t.Fatalf("CloseWrite: %v", err) - } - if !closed { - t.Error("CloseWrite did not reach the underlying connection") - } -} - -func TestCountedConnFallsBackToCloseWhenItCannotHalfClose(t *testing.T) { - // The relay closes a connection outright when it cannot half-close, and - // that full close is what unblocks the opposite copy. A wrapper that - // advertises CloseWrite without delivering one defeats that fallback and - // hangs the relay forever, which is a deadlock no metric test would show. - client, server := net.Pipe() - defer func() { _ = server.Close() }() - - c := &countedConn{Conn: client} // net.Pipe cannot half-close - - // The deadline goes on before the close, both because a closed pipe will - // not accept one and so the pre-fix behaviour reports a blocked read - // rather than hanging the package for the whole test timeout. - if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("SetReadDeadline: %v", err) - } - if err := c.CloseWrite(); err != nil { - t.Fatalf("CloseWrite: %v", err) - } - - if _, err := client.Read(make([]byte, 1)); !errors.Is(err, io.ErrClosedPipe) { - t.Errorf("read after CloseWrite = %v, want the connection closed", err) - } -} - -func TestCountedConnDecrementsOnceOnRepeatedClose(t *testing.T) { - // Both relay directions close their side, so a naive decrement would run - // twice and drive the active-flow gauge negative. - client, server := net.Pipe() - defer func() { _ = server.Close() }() - - c := &countedConn{Conn: client} - flowsActive.Set(0) - flowsActive.Inc() - - _ = c.Close() - _ = c.Close() - - if got := gaugeValue(t, flowsActive); got != 0 { - t.Errorf("flowsActive = %v after two closes, want 0", got) - } -} - -// gaugeValue reads a gauge without pulling in the prometheus test helpers, -// which would add a module for one assertion. -func gaugeValue(t *testing.T, g prometheus.Gauge) float64 { - t.Helper() - var m dto.Metric - if err := g.Write(&m); err != nil { - t.Fatalf("read gauge: %v", err) - } - return m.GetGauge().GetValue() -} - -// halfCloser adds CloseWrite to a connection that lacks one. -type halfCloser struct { - net.Conn - onCloseWrite func() -} - -func (h halfCloser) CloseWrite() error { - h.onCloseWrite() - return nil -} diff --git a/internal/sambox/route.go b/internal/sambox/route.go deleted file mode 100644 index f6582d7e..00000000 --- a/internal/sambox/route.go +++ /dev/null @@ -1,188 +0,0 @@ -// 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. - -package sambox - -import ( - "fmt" - "strings" - - "github.com/google/sam/api" -) - -// Routing decides where a flow leaving a sandbox belongs, from the destination -// name alone. It is deliberately free of I/O so the decision can be tested -// exhaustively and read in one sitting: opening the connection is a separate -// concern. - -// RouteKind is the destination class a flow was resolved to. -type RouteKind int - -const ( - // RouteMeshEntrypoint is the gateway's own agent-facing surface: the mesh - // services an agent may consume, with the provider chosen by policy. It is - // not the node's sidecar API, which an agent never reaches. - RouteMeshEntrypoint RouteKind = iota - - // RouteMeshService is a service provided by some peer in the mesh. Which - // peer is a discovery decision, deliberately not encoded in the name. - RouteMeshService - - // RouteExternal is a destination outside the mesh, permitted by policy. - RouteExternal -) - -func (k RouteKind) String() string { - switch k { - case RouteMeshEntrypoint: - return "mesh-entrypoint" - case RouteMeshService: - return "mesh-service" - case RouteExternal: - return "external" - default: - return fmt.Sprintf("RouteKind(%d)", int(k)) - } -} - -// Route is the outcome of classifying a destination. -type Route struct { - Kind RouteKind - - // ServiceURI is the canonical mesh identity for RouteMeshService, e.g. - // "inference://openrouter". It is the same string policy is written - // against, so a routing decision and an authorization decision can never - // disagree about what was asked for. - ServiceURI string - - Destination Destination -} - -// EgressPolicy is the allowlist for destinations outside the mesh. A nil -// policy allows nothing: a sandbox with no configured egress must reach -// nothing, so the zero value has to be the safe one. -type EgressPolicy struct { - exact map[string]struct{} - suffixes []string -} - -// NewEgressPolicy compiles an allowlist. Entries are either an exact host -// ("api.github.com") or a leading-label wildcard ("*.pypi.org"). Any other use -// of "*" is rejected rather than quietly treated as a literal, because an -// allowlist entry that silently means something other than what it looks like -// is how allowlists leak. -func NewEgressPolicy(allow []string) (*EgressPolicy, error) { - p := &EgressPolicy{exact: make(map[string]struct{}, len(allow))} - for _, raw := range allow { - entry := api.NormalizeMeshHost(raw) - if entry == "" { - return nil, fmt.Errorf("empty egress allow entry") - } - if suffix, found := strings.CutPrefix(entry, "*."); found { - if suffix == "" || strings.Contains(suffix, "*") { - return nil, fmt.Errorf("invalid egress allow entry %q", raw) - } - // Stored with the dot so matching is anchored on a label boundary. - p.suffixes = append(p.suffixes, "."+suffix) - continue - } - if strings.Contains(entry, "*") { - return nil, fmt.Errorf("invalid egress allow entry %q: a wildcard is only allowed as a leading %q label", raw, "*.") - } - p.exact[entry] = struct{}{} - } - return p, nil -} - -// Allows reports whether host may be reached. A wildcard covers subdomains -// only, never the parent, matching how every other wildcard in this system and -// in TLS behaves. -func (p *EgressPolicy) Allows(host string) bool { - if p == nil { - return false - } - h := api.NormalizeMeshHost(host) - if h == "" { - return false - } - if _, ok := p.exact[h]; ok { - return true - } - for _, suffix := range p.suffixes { - if strings.HasSuffix(h, suffix) && len(h) > len(suffix) { - return true - } - } - return false -} - -// AllowsLiteral reports whether an address the guest dialled without ever -// resolving a name may be reached. Only an exact entry can say so: a -// wildcard names a DNS zone, and an address is in no zone. -func (p *EgressPolicy) AllowsLiteral(addr string) bool { - if p == nil { - return false - } - _, ok := p.exact[api.NormalizeMeshHost(addr)] - return ok -} - -// Router classifies destinations arriving on the sandbox boundary. -type Router struct { - // Egress is the allowlist for destinations outside the mesh. Nil denies - // every external destination. - Egress *EgressPolicy -} - -// Route classifies a destination, or returns ErrNotAllowed. Mesh names that do -// not name a service are denied rather than reported as unreachable: to a -// sandbox, "not permitted" and "does not exist" must look the same, or the -// boundary becomes a discovery oracle for the mesh's contents. -func (r *Router) Route(dst Destination) (Route, error) { - if api.IsMeshEntrypointHost(dst.Name) || api.IsMeshHost(dst.Name) { - // Mesh services are HTTP surfaces; a datagram session to one names - // nothing that exists, and is denied like any other non-service. - if dst.network() == "udp" { - return Route{}, fmt.Errorf("%w: %s is not reachable over UDP", ErrNotAllowed, dst.Name) - } - } - - if api.IsMeshEntrypointHost(dst.Name) { - return Route{Kind: RouteMeshEntrypoint, Destination: dst}, nil - } - - if api.IsMeshHost(dst.Name) { - serviceURI, err := api.ParseMeshHost(dst.Name) - if err != nil { - return Route{}, fmt.Errorf("%w: %s names no mesh service", ErrNotAllowed, dst.Name) - } - return Route{Kind: RouteMeshService, ServiceURI: serviceURI, Destination: dst}, nil - } - - // An address carries no name. The guest stack forwards one when a flow - // was opened to an address it never resolved, so policy has nothing to - // decide on but the address itself: only an exact entry allows it. CIDR - // ranges are deliberately not supported; adding them is a policy-language - // decision, not a routing one. - if !dst.IsName { - if !r.Egress.AllowsLiteral(dst.Name) { - return Route{}, fmt.Errorf("%w: %s is an address, not a name", ErrNotAllowed, dst.Name) - } - return Route{Kind: RouteExternal, Destination: dst}, nil - } - if !r.Egress.Allows(dst.Name) { - return Route{}, fmt.Errorf("%w: %s", ErrNotAllowed, dst.Name) - } - return Route{Kind: RouteExternal, Destination: dst}, nil -} diff --git a/internal/sambox/route_test.go b/internal/sambox/route_test.go deleted file mode 100644 index abe2c96d..00000000 --- a/internal/sambox/route_test.go +++ /dev/null @@ -1,179 +0,0 @@ -// 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. - -package sambox - -import ( - "errors" - "testing" -) - -// TestZeroRouterDeniesEverythingExternal pins the fail-closed default: a -// sam-box configured with no egress policy must not be an open proxy. -func TestZeroRouterDeniesEverythingExternal(t *testing.T) { - var r Router - for _, host := range []string{"api.github.com", "127.0.0.1", "localhost", "example.com"} { - if _, err := r.Route(Destination{Name: host, Port: 443, IsName: true}); !errors.Is(err, ErrNotAllowed) { - t.Errorf("Route(%q) with no policy = %v, want ErrNotAllowed", host, err) - } - } -} - -func TestRouteClassification(t *testing.T) { - policy, err := NewEgressPolicy([]string{"api.github.com", "*.pypi.org", "192.0.2.10"}) - if err != nil { - t.Fatalf("NewEgressPolicy: %v", err) - } - r := &Router{Egress: policy} - - tests := []struct { - name string - host string - wantKind RouteKind - wantURI string - wantDenied bool - }{ - {"mesh entrypoint", "mesh.sam.alt", RouteMeshEntrypoint, "", false}, - {"mesh entrypoint is case-insensitive", "MESH.SAM.ALT", RouteMeshEntrypoint, "", false}, - {"mesh inference service", "openrouter.inference.sam.alt", RouteMeshService, "inference://openrouter", false}, - {"mesh mcp service", "code-reviewer.mcp.sam.alt", RouteMeshService, "mcp://code-reviewer", false}, - {"allowlisted host", "api.github.com", RouteExternal, "", false}, - {"allowlisted wildcard subdomain", "files.pypi.org", RouteExternal, "", false}, - {"allowlisted literal address", "192.0.2.10", RouteExternal, "", false}, - - {"unknown external host", "evil.example", 0, "", true}, - {"mesh zone but no service type", "whatever.sam.alt", 0, "", true}, - {"mesh zone with unknown service type", "thing.storage.sam.alt", 0, "", true}, - {"bare mesh zone", "sam.alt", 0, "", true}, - {"lookalike of the mesh zone", "evil-sam.alt", 0, "", true}, - {"lookalike of an allowlisted host", "evil-api.github.com", 0, "", true}, - {"wildcard parent is not covered", "pypi.org", 0, "", true}, - {"lookalike of a wildcard parent", "evilpypi.org", 0, "", true}, - {"unlisted literal address", "192.0.2.11", 0, "", true}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := r.Route(Destination{Name: tc.host, Port: 443, IsName: true}) - if tc.wantDenied { - if !errors.Is(err, ErrNotAllowed) { - t.Fatalf("Route(%q) = %+v, %v; want ErrNotAllowed", tc.host, got, err) - } - return - } - if err != nil { - t.Fatalf("Route(%q) returned error: %v", tc.host, err) - } - if got.Kind != tc.wantKind { - t.Errorf("Route(%q) kind = %v, want %v", tc.host, got.Kind, tc.wantKind) - } - if got.ServiceURI != tc.wantURI { - t.Errorf("Route(%q) service = %q, want %q", tc.host, got.ServiceURI, tc.wantURI) - } - if got.Destination.Name != tc.host { - t.Errorf("Route(%q) lost the destination: %+v", tc.host, got.Destination) - } - }) - } -} - -// TestMeshNamesIgnoreEgressPolicy pins that mesh routing is not reachable -// through the allowlist: a mesh name is authorized by mesh policy, and an -// operator listing it under egress must not change how it is routed. -// TestLiteralAddressesNeedAnExactEntry: the guest stack forwards an address -// when a flow was opened to one it never resolved. Policy is written on -// names, so a wildcard cannot cover it; only an exact entry does, and with no -// policy at all it is denied like everything else. -func TestLiteralAddressesNeedAnExactEntry(t *testing.T) { - policy, err := NewEgressPolicy([]string{"*.3.4", "198.51.100.7", "api.github.com"}) - if err != nil { - t.Fatalf("NewEgressPolicy: %v", err) - } - r := &Router{Egress: policy} - - for _, addr := range []string{"1.2.3.4", "2001:db8::1", "10.0.0.1"} { - if _, err := r.Route(Destination{Name: addr, Port: 443, IsName: false}); !errors.Is(err, ErrNotAllowed) { - t.Errorf("Route(%q as address) = %v, want ErrNotAllowed", addr, err) - } - } - got, err := r.Route(Destination{Name: "198.51.100.7", Port: 5432, IsName: false}) - if err != nil || got.Kind != RouteExternal { - t.Errorf("an exactly listed address = %+v, %v; want RouteExternal", got, err) - } - // The same string as a name is still matched as a name. - if _, err := r.Route(Destination{Name: "x.3.4", Port: 443, IsName: true}); err != nil { - t.Errorf("a name under the wildcard: %v", err) - } - if _, err := (&Router{}).Route(Destination{Name: "198.51.100.7", Port: 443, IsName: false}); !errors.Is(err, ErrNotAllowed) { - t.Errorf("an address with no policy = %v, want ErrNotAllowed", err) - } -} - -func TestMeshNamesIgnoreEgressPolicy(t *testing.T) { - policy, err := NewEgressPolicy([]string{"*.sam.alt"}) - if err != nil { - t.Fatalf("NewEgressPolicy: %v", err) - } - r := &Router{Egress: policy} - - got, err := r.Route(Destination{Name: "openrouter.inference.sam.alt", Port: 80, IsName: true}) - if err != nil { - t.Fatalf("Route returned error: %v", err) - } - if got.Kind != RouteMeshService { - t.Errorf("kind = %v, want %v", got.Kind, RouteMeshService) - } - - if _, err := r.Route(Destination{Name: "nothing.sam.alt", Port: 80, IsName: true}); !errors.Is(err, ErrNotAllowed) { - t.Errorf("a non-service mesh name was allowed by the egress list: %v", err) - } -} - -func TestNewEgressPolicyRejectsAmbiguousEntries(t *testing.T) { - tests := []struct { - name string - entry string - }{ - {"empty", ""}, - {"bare wildcard", "*"}, - {"trailing wildcard", "github.*"}, - {"infix wildcard", "api.*.com"}, - {"partial label wildcard", "*api.github.com"}, - {"double wildcard", "*.*.github.com"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if _, err := NewEgressPolicy([]string{tc.entry}); err == nil { - t.Fatalf("NewEgressPolicy(%q) = nil error, want a rejection", tc.entry) - } - }) - } -} - -func TestEgressPolicyNormalizesEntriesAndHosts(t *testing.T) { - policy, err := NewEgressPolicy([]string{"API.GitHub.com.", "*.PyPI.org"}) - if err != nil { - t.Fatalf("NewEgressPolicy: %v", err) - } - - for _, host := range []string{"api.github.com", "API.GITHUB.COM", "api.github.com.", "files.PyPI.org."} { - if !policy.Allows(host) { - t.Errorf("Allows(%q) = false, want true", host) - } - } - if policy.Allows("") { - t.Error("Allows(\"\") = true, want false") - } -} diff --git a/internal/storage/mesh_policy_test.go b/internal/storage/mesh_policy_test.go index 6cc95c65..d6ab1686 100644 --- a/internal/storage/mesh_policy_test.go +++ b/internal/storage/mesh_policy_test.go @@ -45,7 +45,6 @@ func TestMeshPolicyRoundTripsEveryRoleField(t *testing.T) { AllowedTargets: []string{"group:backend"}, AllowedServices: []string{"mcp://tool"}, CustomDatalog: []string{`region("emea")`}, - AllowedAgents: []string{"*.prod.acme.example"}, AllowedLabels: []string{"region=*"}, Http: []*api.HTTPGrant{{Service: "mcp://tool", Methods: []string{"GET"}, Paths: []string{"/v1/*"}}}, } diff --git a/internal/storage/sql_store.go b/internal/storage/sql_store.go index fc5465b6..53679bdf 100644 --- a/internal/storage/sql_store.go +++ b/internal/storage/sql_store.go @@ -1080,11 +1080,6 @@ func (s *SQLStore) saveMeshPolicyTx(ctx context.Context, tx *sql.Tx, roles []*ap return err } } - for _, agent := range r.AllowedAgents { - if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'agent', ?)"), r.Name, agent); err != nil { - return err - } - } for _, label := range r.AllowedLabels { if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'label', ?)"), r.Name, label); err != nil { return err @@ -1163,8 +1158,6 @@ func (s *SQLStore) GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api r.AllowedServices = append(r.AllowedServices, resValue) case "custom_datalog": r.CustomDatalog = append(r.CustomDatalog, resValue) - case "agent": - r.AllowedAgents = append(r.AllowedAgents, resValue) case "label": r.AllowedLabels = append(r.AllowedLabels, resValue) case "http": diff --git a/sdk/js/src/authorizer.test.ts b/sdk/js/src/authorizer.test.ts index 9860edbb..08e3f05b 100644 --- a/sdk/js/src/authorizer.test.ts +++ b/sdk/js/src/authorizer.test.ts @@ -61,12 +61,8 @@ function options(policyRules: string[], ownBiscuit?: Uint8Array) { }; } -function request(biscuit: Uint8Array, targetService = "mcp://calc", agent?: string): AuthorizeRequest { - const req: AuthorizeRequest = { biscuit, peerId: CALLER, targetService, protocol: "/sam/mcp/1.0.0" }; - if (agent !== undefined) { - req.agent = agent; - } - return req; +function request(biscuit: Uint8Array, targetService = "mcp://calc"): AuthorizeRequest { + return { biscuit, peerId: CALLER, targetService, protocol: "/sam/mcp/1.0.0" }; } // The role grants the service through the mesh policy rules, exactly as the @@ -146,15 +142,6 @@ test("target grants are matched against the provider's own identity", async () = await assert.rejects(authorizeCaller(request(nodeToken(CALLER)), options(rules, provider)), AuthorizationError); }); -test("an agent claim is accepted only inside a granted namespace", async () => { - const rules = [...NODE_ROLE_GRANTS, `granted_agent_suffix(".acme.example") <- role("sam:role:node")`]; - await authorizeCaller(request(nodeToken(CALLER), "mcp://calc", "reviewer.acme.example"), options(rules)); - await assert.rejects(authorizeCaller(request(nodeToken(CALLER), "mcp://calc", "reviewer.evil.example"), options(rules)), AuthorizationError); - // No agent grant at all: any claim is refused, no claim is fine. - await assert.rejects(authorizeCaller(request(nodeToken(CALLER), "mcp://calc", "reviewer.acme.example"), options(NODE_ROLE_GRANTS)), AuthorizationError); - await authorizeCaller(request(nodeToken(CALLER)), options(NODE_ROLE_GRANTS)); -}); - test("a grant narrowed by PolicyRole.http follows the request's method and path", async () => { // Rendered as the control plane renders a role with // http: [{service: "mcp://calc", methods: ["GET"], paths: ["/v1/*"]}]: @@ -175,10 +162,10 @@ test("a grant narrowed by PolicyRole.http follows the request's method and path" }); test("every baseline item parses in biscuit-wasm", () => { - for (const c of [BASELINE_DATALOG.time_check, BASELINE_DATALOG.replay_check, BASELINE_DATALOG.target_check, BASELINE_DATALOG.agent_check]) { + for (const c of [BASELINE_DATALOG.time_check, BASELINE_DATALOG.replay_check, BASELINE_DATALOG.target_check]) { wasm.Check.fromString(c); } - for (const r of [...BASELINE_DATALOG.rules, ...BASELINE_DATALOG.http_rules, ...BASELINE_DATALOG.agent_rules, ...BASELINE_DATALOG.target_fact_rules]) { + for (const r of [...BASELINE_DATALOG.rules, ...BASELINE_DATALOG.http_rules, ...BASELINE_DATALOG.target_fact_rules]) { wasm.Rule.fromString(r); } for (const p of [...BASELINE_DATALOG.policies, BASELINE_DATALOG.allow_if_true]) { diff --git a/sdk/js/src/authorizer.ts b/sdk/js/src/authorizer.ts index 3812cb5c..97489d5a 100644 --- a/sdk/js/src/authorizer.ts +++ b/sdk/js/src/authorizer.ts @@ -31,8 +31,6 @@ export interface AuthorizeRequest { targetService: string; /** The stream protocol; names the service when targetService is "". */ protocol: string; - /** The agent the caller says it acts for; its own claim, checked against its grants. */ - agent?: string; /** * The HTTP method and the path as the backend sees it, when the request is * HTTP. Both are injected together; a request without them (a stream that @@ -136,16 +134,6 @@ export async function authorizeCaller(req: AuthorizeRequest, options: ProviderAu fact(`${BASELINE_DATALOG.fact_path}({p})`, { p: req.path ?? "" }); } - // The caller's word about which agent it acts for, limited to the agent - // namespaces its own token grants. - if (req.agent) { - fact(`${BASELINE_DATALOG.fact_agent}({a})`, { a: req.agent }); - for (const r of BASELINE_DATALOG.agent_rules) { - b.addRule(wasm.Rule.fromString(r)); - } - b.addCheck(wasm.Check.fromString(BASELINE_DATALOG.agent_check)); - } - b.addCheck(wasm.Check.fromString(BASELINE_DATALOG.replay_check)); b.addCheck(wasm.Check.fromString(BASELINE_DATALOG.time_check)); diff --git a/sdk/js/src/conformance.ts b/sdk/js/src/conformance.ts index f0881118..14e3cde7 100644 --- a/sdk/js/src/conformance.ts +++ b/sdk/js/src/conformance.ts @@ -60,7 +60,7 @@ async function main(): Promise { reloaded_peer_id: reloaded.peerId, refreshed_biscuit: b64(refreshed.biscuit), refreshed_expiration: refreshed.expiration, - auth_frame: b64(reloaded.authFrame("mcp://echo", "agent:example.test:conformance")), + auth_frame: b64(reloaded.authFrame("mcp://echo")), }) + "\n", ); } diff --git a/sdk/js/src/credential.ts b/sdk/js/src/credential.ts index 8f979b60..9f21ff94 100644 --- a/sdk/js/src/credential.ts +++ b/sdk/js/src/credential.ts @@ -56,11 +56,11 @@ export function credentialTimeToLiveSeconds(c: MeshCredential, nowMs = Date.now( /** * The first frame on every mesh stream (/sam/auth/1.0.0, /sam/mcp/1.0.0): - * the caller's biscuit, the service it wants and the agent it speaks for. + * the caller's biscuit and the service it wants. * Framing (varint length prefix) is the transport's job. */ -export function encodeAuthFrame(biscuit: Uint8Array, targetService = "", agent = ""): Uint8Array { - return toBinary(AuthFrameSchema, create(AuthFrameSchema, { biscuit, targetService, agent })); +export function encodeAuthFrame(biscuit: Uint8Array, targetService = ""): Uint8Array { + return toBinary(AuthFrameSchema, create(AuthFrameSchema, { biscuit, targetService })); } /** The peer's answer to an AuthFrame, carrying its own biscuit on success. */ diff --git a/sdk/js/src/gen/datalog.ts b/sdk/js/src/gen/datalog.ts index a2ad2b7a..89a995d1 100644 --- a/sdk/js/src/gen/datalog.ts +++ b/sdk/js/src/gen/datalog.ts @@ -31,13 +31,6 @@ export const BASELINE_DATALOG = { "granted_service_all($t) <- service($t, $n), http_granted_service_all($t), http_method_ok($t, \"*\"), http_path_ok($t, \"*\")", "granted_service_all_types(true) <- service($t, $n), http_granted_service_all_types(true), http_method_ok(\"*\", \"*\"), http_path_ok(\"*\", \"*\")" ], - "agent_rules": [ - "agent_authorized(true) <- agent($a), granted_agent_exact($a)", - "agent_authorized(true) <- agent($a), granted_agent_set($set), $set.contains($a)", - "agent_authorized(true) <- agent($a), granted_agent_prefix($prefix), $a.starts_with($prefix)", - "agent_authorized(true) <- agent($a), granted_agent_suffix($suffix), $a.ends_with($suffix)", - "agent_authorized(true) <- agent($a), granted_agent_all(true)" - ], "target_fact_rules": [ "target_fact(\"email\", $val) <- email($val)", "target_fact(\"group\", $val) <- group($val)", @@ -47,12 +40,10 @@ export const BASELINE_DATALOG = { ], "replay_check": "check if client_peer_id($id), connection_peer_id($id)", "target_check": "check if allow_network_target($fact, $val) or target_unrestricted(true)", - "agent_check": "check if agent_authorized(true)", "time_check": "check if time($time), expiration($exp), $time <= $exp", "allow_if_true": "allow if true", "fact_service": "service", "fact_connection_peer_id": "connection_peer_id", - "fact_agent": "agent", "fact_method": "method", "fact_path": "path", "fact_time": "time", diff --git a/sdk/js/src/gen/sam_pb.ts b/sdk/js/src/gen/sam_pb.ts index 4ee3d093..8e2a440c 100644 --- a/sdk/js/src/gen/sam_pb.ts +++ b/sdk/js/src/gen/sam_pb.ts @@ -26,7 +26,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file sam.proto. */ export const file_sam: GenFile = /*@__PURE__*/ - fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSJDCglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCRINCgVhZ2VudBgDIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK2AQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfYWdlbnRzGAUgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAYgAygJEh8KBGh0dHAYByADKAsyES5zYW0udjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkiXAoRRWdyZXNzRGVzdGluYXRpb24SDAoEbmFtZRgBIAEoCRISCgp0YXJnZXRfdXJsGAIgASgJEhIKCmNyZWRlbnRpYWwYAyABKAkSEQoJc2VydmVkX2J5GAQgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIoUBCgxQb2xpY3lDb25maWcSIQoFcm9sZXMYASADKAsyEi5zYW0udjEuUG9saWN5Um9sZRInCghiaW5kaW5ncxgCIAMoCzIVLnNhbS52MS5Qb2xpY3lCaW5kaW5nEikKBmVncmVzcxgDIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0Ik0KF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYAyADKAlKBAgBEAJKBAgCEANSBXJvbGVzUghiaW5kaW5ncyI8ChpQb2xpY3lDb25maWdVcGRhdGVSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIhoKGEVncmVzc0Fzc2lnbm1lbnRzUmVxdWVzdCJGChlFZ3Jlc3NBc3NpZ25tZW50c1Jlc3BvbnNlEikKBmVncmVzcxgBIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiJmCgxLZXlzUmVzcG9uc2USEwoLcHVibGljX2tleXMYASADKAwSLQoJc2lnbl90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpzaWduYXR1cmVzGAMgAygMIl4KE1Rva2VuUmVmcmVzaFJlcXVlc3QSGwoTY2hhbGxlbmdlX3NpZ25hdHVyZRgBIAEoDBIZChFjaGFsbGVuZ2VfdW5peF9tcxgCIAEoAxIPCgdwZWVyX2lkGAMgASgJInUKFFRva2VuUmVmcmVzaFJlc3BvbnNlEhUKDWJpc2N1aXRfdG9rZW4YASABKAwSLwoLZXhwaXJlX3RpbWUYAiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhUKDWVycm9yX21lc3NhZ2UYAyABKAkiOgoRTm9kZUNhdGFsb2dSZXBvcnQSJQoIc2VydmljZXMYASADKAsyEy5zYW0udjEuU2VydmljZUluZm8iJQoSVG9rZW5SZXZva2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkiNQoTVG9rZW5SZXZva2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJIlIKC0FnZW50U2VjcmV0EgwKBGhvc3QYASABKAkSDAoEa2luZBgCIAEoCRITCgtoZWFkZXJfbmFtZRgDIAEoCRISCgp2YWx1ZV9wYXRoGAQgASgJIkIKC0FnZW50RWdyZXNzEg0KBWFsbG93GAEgAygJEiQKB3NlY3JldHMYAiADKAsyEy5zYW0udjEuQWdlbnRTZWNyZXQiYgoMQWdlbnRJbmdyZXNzEiEKBHR5cGUYASABKA4yEy5zYW0udjEuU2VydmljZVR5cGUSDAoEbmFtZRgCIAEoCRIMCgRwb3J0GAMgASgNEhMKC2Rlc2NyaXB0aW9uGAQgASgJIqoBCgtBZ2VudEJ1bmRsZRIPCgd2ZXJzaW9uGAEgASgJEhAKCGFnZW50X2lkGAIgASgJEhMKC2V4dGVybmFsX2lkGAMgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgEIAEoCRIjCgZlZ3Jlc3MYBSABKAsyEy5zYW0udjEuQWdlbnRFZ3Jlc3MSJQoHaW5ncmVzcxgGIAMoCzIULnNhbS52MS5BZ2VudEluZ3Jlc3MiOQoSQWdlbnRBdHRhY2hSZXF1ZXN0EiMKBmJ1bmRsZRgBIAEoCzITLnNhbS52MS5BZ2VudEJ1bmRsZSJTChNBZ2VudEF0dGFjaFJlc3BvbnNlEhUKDWVncmVzc19zb2NrZXQYASABKAkSFgoOaW5ncmVzc19zb2NrZXQYAiABKAkSDQoFZXJyb3IYAyABKAkiJgoSQWdlbnREZXRhY2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJIjUKE0FnZW50RGV0YWNoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSJAChNBZ2VudFJlZnJlc2hSZXF1ZXN0EhAKCGFnZW50X2lkGAEgASgJEhcKD2NyZWRlbnRpYWxfcGF0aBgCIAEoCSJnChRBZ2VudFJlZnJlc2hSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCImChJBZ2VudFN0YXR1c1JlcXVlc3QSEAoIYWdlbnRfaWQYASABKAkilAEKC0FnZW50U3RhdHVzEhAKCGFnZW50X2lkGAEgASgJEhAKCGF0dGFjaGVkGAIgASgIEiUKB2luZ3Jlc3MYAyADKAsyFC5zYW0udjEuQWdlbnRJbmdyZXNzEjoKFmNyZWRlbnRpYWxfZXhwaXJlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkkKE0FnZW50U3RhdHVzUmVzcG9uc2USIwoGYWdlbnRzGAEgAygLMhMuc2FtLnYxLkFnZW50U3RhdHVzEg0KBWVycm9yGAIgASgJIuQBChhJZGVudGl0eUV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEjcKE2Jpc2N1aXRfZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAQgASgJEiIKGnRydXN0ZWRfY29udHJvbF9wbGFuZV9rZXlzGAUgAygMEi4KCmNoZWNrX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIsACChRQZWVyRXZpZGVuY2VSZXNwb25zZRIPCgdwZWVyX2lkGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSFQoNdmVyaWZ5aW5nX2tleRgDIAEoDBINCgVyb2xlcxgEIAMoCRI4CgZsYWJlbHMYBSADKAsyKC5zYW0udjEuUGVlckV2aWRlbmNlUmVzcG9uc2UuTGFiZWxzRW50cnkSLwoLZXhwaXJlX3RpbWUYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDnJldm9jYXRpb25faWRzGAcgAygJEi4KCmNoZWNrX3RpbWUYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wGi0KC0xhYmVsc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEigAIKEE1lbWJlckNyZWRlbnRpYWwSGQoRY29udHJvbF9wbGFuZV91cmwYASABKAkSDwoHYmlzY3VpdBgCIAEoDBIvCgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoMdHJ1c3RlZF9rZXlzGAQgAygLMhkuc2FtLnYxLlRydXN0ZWRTaWduaW5nS2V5EhkKEWlzc3VlZF91bmRlcl9rZXlzGAUgAygMEhgKEHJvdXRlcl9hZGRyZXNzZXMYBiADKAkSKQoMb2lkY19zZXNzaW9uGAcgASgLMhMuc2FtLnYxLk9JRENTZXNzaW9uIlkKEVRydXN0ZWRTaWduaW5nS2V5EhIKCnB1YmxpY19rZXkYASABKAwSMAoMcmVjZWl2ZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJZCgtPSURDU2Vzc2lvbhIOCgZpc3N1ZXIYASABKAkSEQoJY2xpZW50X2lkGAIgASgJEhAKCGF1ZGllbmNlGAMgASgJEhUKDXJlZnJlc2hfdG9rZW4YBCABKAkqlAEKEEVucm9sbG1lbnRTdGF0dXMSIQodRU5ST0xMTUVOVF9TVEFUVVNfVU5TUEVDSUZJRUQQABIdChlFTlJPTExNRU5UX1NUQVRVU19QRU5ESU5HEAESHgoaRU5ST0xMTUVOVF9TVEFUVVNfQVBQUk9WRUQQAhIeChpFTlJPTExNRU5UX1NUQVRVU19SRUpFQ1RFRBADKowBCgtTZXJ2aWNlVHlwZRIcChhTRVJWSUNFX1RZUEVfVU5TUEVDSUZJRUQQABIUChBTRVJWSUNFX1RZUEVfTUNQEAESGgoWU0VSVklDRV9UWVBFX0lORkVSRU5DRRACEhQKEFNFUlZJQ0VfVFlQRV9BMkEQAxIXChNTRVJWSUNFX1RZUEVfRUdSRVNTEARCG1oZZ2l0aHViLmNvbS9nb29nbGUvc2FtL2FwaWIGcHJvdG8z", [file_google_protobuf_timestamp]); + fileDesc("CglzYW0ucHJvdG8SBnNhbS52MSI0CglBdXRoRnJhbWUSDwoHYmlzY3VpdBgBIAEoDBIWCg50YXJnZXRfc2VydmljZRgCIAEoCSI/CgxBdXRoUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCRIPCgdiaXNjdWl0GAMgASgMItYBCglNZXNoRXZlbnQSJAoEdHlwZRgBIAEoDjIWLnNhbS52MS5NZXNoRXZlbnQuVHlwZRIPCgdwZWVyX2lkGAIgASgJEi4KCmV2ZW50X3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhYKDm5ld19wdWJsaWNfa2V5GAQgASgMEhEKCXNpZ25hdHVyZRgFIAEoDCI3CgRUeXBlEgoKBkJBTk5FRBAAEhAKDEtFWV9ST1RBVElPThABEhEKDVBPTElDWV9VUERBVEUQAiLzAQoNRW5yb2xsUmVxdWVzdBILCgNqd3QYASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjEKBmxhYmVscxgFIAMoCzIhLnNhbS52MS5FbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKrAQoORW5yb2xsUmVzcG9uc2USFQoNYmlzY3VpdF90b2tlbhgBIAEoDBIVCg1lcnJvcl9tZXNzYWdlGAIgASgJEiAKGGNvbnRyb2xfcGxhbmVfcHVibGljX2tleRgDIAEoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEi8KC2V4cGlyZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKRAgoWQm9vdHN0cmFwRW5yb2xsUmVxdWVzdBIXCg9ib290c3RyYXBfdG9rZW4YASABKAkSDwoHcGVlcl9pZBgCIAEoCRISCgpwdWJsaWNfa2V5GAMgASgMEhYKDnJlcXVlc3RlZF9yb2xlGAQgASgJEjoKBmxhYmVscxgFIAMoCzIqLnNhbS52MS5Cb290c3RyYXBFbnJvbGxSZXF1ZXN0LkxhYmVsc0VudHJ5EhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASL9AQoXQm9vdHN0cmFwRW5yb2xsUmVzcG9uc2USKAoGc3RhdHVzGAEgASgOMhguc2FtLnYxLkVucm9sbG1lbnRTdGF0dXMSFQoNYmlzY3VpdF90b2tlbhgCIAEoDBIdChVwb2xsX2ludGVydmFsX3NlY29uZHMYAyABKAUSFQoNZXJyb3JfbWVzc2FnZRgEIAEoCRIgChhjb250cm9sX3BsYW5lX3B1YmxpY19rZXkYBSABKAwSGAoQcm91dGVyX2FkZHJlc3NlcxgGIAMoCRIvCgtleHBpcmVfdGltZRgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiUwoLU2VydmljZUluZm8SIQoEdHlwZRgBIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIMCgRuYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJInsKDkNvbW1hbmRCYWNrZW5kEg8KB2NvbW1hbmQYASADKAkSLAoDZW52GAIgAygLMh8uc2FtLnYxLkNvbW1hbmRCYWNrZW5kLkVudkVudHJ5GioKCEVudkVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiigEKFlJlZ2lzdGVyU2VydmljZVJlcXVlc3QSJAoHc2VydmljZRgBIAEoCzITLnNhbS52MS5TZXJ2aWNlSW5mbxIUCgp0YXJnZXRfdXJsGAIgASgJSAASKQoHY29tbWFuZBgDIAEoCzIWLnNhbS52MS5Db21tYW5kQmFja2VuZEgAQgkKB2JhY2tlbmQiaQoSRGlzY292ZXJlZFByb3ZpZGVyEg8KB3BlZXJfaWQYASABKAkSFwoPbG9jYWxfcHJveHlfdXJsGAIgASgJEhAKCHNydl9uYW1lGAMgASgJEhcKD3Nydl9kZXNjcmlwdGlvbhgEIAEoCSKyAgoPU2VydmljZUFubm91bmNlEg8KB3BlZXJfaWQYASABKAkSIQoEdHlwZRgCIAEoDjITLnNhbS52MS5TZXJ2aWNlVHlwZRIUCgxzZXJ2aWNlX25hbWUYAyABKAkSDAoEa2V5cxgEIAMoCRIzCgZsYWJlbHMYBSADKAsyIy5zYW0udjEuU2VydmljZUFubm91bmNlLkxhYmVsc0VudHJ5EhcKD2FjdGl2ZV9yZXF1ZXN0cxgGIAEoDRIXCg9sYXRlbmN5X2V3bWFfbXMYByABKAESMQoNYW5ub3VuY2VfdGltZRgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAaLQoLTGFiZWxzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASKHAQoYQ29udHJvbFBsYW5lSW5mb1Jlc3BvbnNlEhMKC29pZGNfaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIYChByb3V0ZXJfYWRkcmVzc2VzGAQgAygJEhcKD2Jhbm5lZF9wZWVyX2lkcxgFIAMoCSKsAQoSUm91dGVyTGVhc2VSZXF1ZXN0Eg8KB3BlZXJfaWQYASABKAkSEQoJYWRkcmVzc2VzGAIgAygJEg8KB2Jpc2N1aXQYAyABKAwSFwoPY29ubmVjdGVkX3BlZXJzGAQgAygJEhAKCGRodF9zaXplGAUgASgFEhkKEWNoYWxsZW5nZV91bml4X21zGAYgASgDEhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYByABKAwiZgoTUm91dGVyTGVhc2VSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg0KBWVycm9yGAIgASgJEi8KC2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKeAQoKUG9saWN5Um9sZRIMCgRuYW1lGAEgASgJEhcKD2FsbG93ZWRfdGFyZ2V0cxgCIAMoCRIYChBhbGxvd2VkX3NlcnZpY2VzGAMgAygJEhYKDmN1c3RvbV9kYXRhbG9nGAQgAygJEhYKDmFsbG93ZWRfbGFiZWxzGAUgAygJEh8KBGh0dHAYBiADKAsyES5zYW0udjEuSFRUUEdyYW50IjwKCUhUVFBHcmFudBIPCgdzZXJ2aWNlGAEgASgJEg8KB21ldGhvZHMYAiADKAkSDQoFcGF0aHMYAyADKAkiXAoRRWdyZXNzRGVzdGluYXRpb24SDAoEbmFtZRgBIAEoCRISCgp0YXJnZXRfdXJsGAIgASgJEhIKCmNyZWRlbnRpYWwYAyABKAkSEQoJc2VydmVkX2J5GAQgAygJIi4KDVBvbGljeUJpbmRpbmcSDAoEcm9sZRgBIAEoCRIPCgdtZW1iZXJzGAIgAygJIoUBCgxQb2xpY3lDb25maWcSIQoFcm9sZXMYASADKAsyEi5zYW0udjEuUG9saWN5Um9sZRInCghiaW5kaW5ncxgCIAMoCzIVLnNhbS52MS5Qb2xpY3lCaW5kaW5nEikKBmVncmVzcxgDIAMoCzIZLnNhbS52MS5FZ3Jlc3NEZXN0aW5hdGlvbiIYChZQb2xpY3lDb25maWdHZXRSZXF1ZXN0IjAKF1BvbGljeUNvbmZpZ0dldFJlc3BvbnNlEhUKDWRhdGFsb2dfcnVsZXMYASADKAkiPAoaUG9saWN5Q29uZmlnVXBkYXRlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSIaChhFZ3Jlc3NBc3NpZ25tZW50c1JlcXVlc3QiRgoZRWdyZXNzQXNzaWdubWVudHNSZXNwb25zZRIpCgZlZ3Jlc3MYASADKAsyGS5zYW0udjEuRWdyZXNzRGVzdGluYXRpb24iZgoMS2V5c1Jlc3BvbnNlEhMKC3B1YmxpY19rZXlzGAEgAygMEi0KCXNpZ25fdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASEgoKc2lnbmF0dXJlcxgDIAMoDCJeChNUb2tlblJlZnJlc2hSZXF1ZXN0EhsKE2NoYWxsZW5nZV9zaWduYXR1cmUYASABKAwSGQoRY2hhbGxlbmdlX3VuaXhfbXMYAiABKAMSDwoHcGVlcl9pZBgDIAEoCSJ1ChRUb2tlblJlZnJlc2hSZXNwb25zZRIVCg1iaXNjdWl0X3Rva2VuGAEgASgMEi8KC2V4cGlyZV90aW1lGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIjoKEU5vZGVDYXRhbG9nUmVwb3J0EiUKCHNlcnZpY2VzGAEgAygLMhMuc2FtLnYxLlNlcnZpY2VJbmZvIiUKElRva2VuUmV2b2tlUmVxdWVzdBIPCgdwZWVyX2lkGAEgASgJIjUKE1Rva2VuUmV2b2tlUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCBINCgVlcnJvchgCIAEoCSLkAQoYSWRlbnRpdHlFdmlkZW5jZVJlc3BvbnNlEg8KB3BlZXJfaWQYASABKAkSDwoHYmlzY3VpdBgCIAEoDBI3ChNiaXNjdWl0X2V4cGlyZV90aW1lGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIZChFjb250cm9sX3BsYW5lX3VybBgEIAEoCRIiChp0cnVzdGVkX2NvbnRyb2xfcGxhbmVfa2V5cxgFIAMoDBIuCgpjaGVja190aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCLAAgoUUGVlckV2aWRlbmNlUmVzcG9uc2USDwoHcGVlcl9pZBgBIAEoCRIPCgdiaXNjdWl0GAIgASgMEhUKDXZlcmlmeWluZ19rZXkYAyABKAwSDQoFcm9sZXMYBCADKAkSOAoGbGFiZWxzGAUgAygLMiguc2FtLnYxLlBlZXJFdmlkZW5jZVJlc3BvbnNlLkxhYmVsc0VudHJ5Ei8KC2V4cGlyZV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIWCg5yZXZvY2F0aW9uX2lkcxgHIAMoCRIuCgpjaGVja190aW1lGAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBotCgtMYWJlbHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIoACChBNZW1iZXJDcmVkZW50aWFsEhkKEWNvbnRyb2xfcGxhbmVfdXJsGAEgASgJEg8KB2Jpc2N1aXQYAiABKAwSLwoLZXhwaXJlX3RpbWUYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KDHRydXN0ZWRfa2V5cxgEIAMoCzIZLnNhbS52MS5UcnVzdGVkU2lnbmluZ0tleRIZChFpc3N1ZWRfdW5kZXJfa2V5cxgFIAMoDBIYChByb3V0ZXJfYWRkcmVzc2VzGAYgAygJEikKDG9pZGNfc2Vzc2lvbhgHIAEoCzITLnNhbS52MS5PSURDU2Vzc2lvbiJZChFUcnVzdGVkU2lnbmluZ0tleRISCgpwdWJsaWNfa2V5GAEgASgMEjAKDHJlY2VpdmVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiWQoLT0lEQ1Nlc3Npb24SDgoGaXNzdWVyGAEgASgJEhEKCWNsaWVudF9pZBgCIAEoCRIQCghhdWRpZW5jZRgDIAEoCRIVCg1yZWZyZXNoX3Rva2VuGAQgASgJKpQBChBFbnJvbGxtZW50U3RhdHVzEiEKHUVOUk9MTE1FTlRfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHQoZRU5ST0xMTUVOVF9TVEFUVVNfUEVORElORxABEh4KGkVOUk9MTE1FTlRfU1RBVFVTX0FQUFJPVkVEEAISHgoaRU5ST0xMTUVOVF9TVEFUVVNfUkVKRUNURUQQAyqMAQoLU2VydmljZVR5cGUSHAoYU0VSVklDRV9UWVBFX1VOU1BFQ0lGSUVEEAASFAoQU0VSVklDRV9UWVBFX01DUBABEhoKFlNFUlZJQ0VfVFlQRV9JTkZFUkVOQ0UQAhIUChBTRVJWSUNFX1RZUEVfQTJBEAMSFwoTU0VSVklDRV9UWVBFX0VHUkVTUxAEQhtaGWdpdGh1Yi5jb20vZ29vZ2xlL3NhbS9hcGliBnByb3RvMw", [file_google_protobuf_timestamp]); /** * @generated from message sam.v1.AuthFrame @@ -43,16 +43,6 @@ export type AuthFrame = Message<"sam.v1.AuthFrame"> & { * @generated from field: string target_service = 2; */ targetService: string; - - /** - * The agent this request is made for, as a canonical agent identifier (see - * api/agent.go). It is the calling node's claim, carried beside the token - * because Biscuit hides an appended block's facts from the authorizer; the - * HTTP datapath carries the same claim in HeaderSamAgent. - * - * @generated from field: string agent = 3; - */ - agent: string; }; /** @@ -707,29 +697,19 @@ export type PolicyRole = Message<"sam.v1.PolicyRole"> & { */ customDatalog: string[]; - /** - * Agent namespaces the holder may speak for, e.g. "*.prod.acme.example". - * An agent claim is the calling node's word, so it is only worth what the - * control plane attested about that node. Distinct from allowed_targets: - * being allowed to call an agent is not being allowed to impersonate it. - * - * @generated from field: repeated string allowed_agents = 5; - */ - allowedAgents: string[]; - /** * Labels a node with this role may declare at enrollment, as "*", "key=*" * or "key=value". A node declares its own labels, so this is what turns a * declaration into something the control plane is willing to sign. * - * @generated from field: repeated string allowed_labels = 6; + * @generated from field: repeated string allowed_labels = 5; */ allowedLabels: string[]; /** * HTTP narrowing of allowed_services entries; see HTTPGrant. * - * @generated from field: repeated sam.v1.HTTPGrant http = 7; + * @generated from field: repeated sam.v1.HTTPGrant http = 6; */ http: HTTPGrant[]; }; @@ -914,7 +894,7 @@ export type PolicyConfigGetResponse = Message<"sam.v1.PolicyConfigGetResponse"> /** * One rule per entry, rendered by the control plane with api.BuildPolicyRules. * - * @generated from field: repeated string datalog_rules = 3; + * @generated from field: repeated string datalog_rules = 1; */ datalogRules: string[]; }; @@ -1149,385 +1129,6 @@ export type TokenRevokeResponse = Message<"sam.v1.TokenRevokeResponse"> & { export const TokenRevokeResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_sam, 30); -/** - * AgentSecret configures credential injection for one destination. It carries - * a path, never a value: secret material must not travel through this API. - * - * @generated from message sam.v1.AgentSecret - */ -export type AgentSecret = Message<"sam.v1.AgentSecret"> & { - /** - * @generated from field: string host = 1; - */ - host: string; - - /** - * bearer | basicauth | customheader - * - * @generated from field: string kind = 2; - */ - kind: string; - - /** - * customheader only - * - * @generated from field: string header_name = 3; - */ - headerName: string; - - /** - * @generated from field: string value_path = 4; - */ - valuePath: string; -}; - -/** - * Describes the message sam.v1.AgentSecret. - * Use `create(AgentSecretSchema)` to create a new message. - */ -export const AgentSecretSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 31); - -/** - * AgentEgress is deny-by-default. Patterns are matched against the destination - * name taken from the sandbox boundary, never against a resolved address. - * - * @generated from message sam.v1.AgentEgress - */ -export type AgentEgress = Message<"sam.v1.AgentEgress"> & { - /** - * @generated from field: repeated string allow = 1; - */ - allow: string[]; - - /** - * @generated from field: repeated sam.v1.AgentSecret secrets = 2; - */ - secrets: AgentSecret[]; -}; - -/** - * Describes the message sam.v1.AgentEgress. - * Use `create(AgentEgressSchema)` to create a new message. - */ -export const AgentEgressSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 32); - -/** - * AgentIngress declares that the agent serves a mesh service. The name is the - * service half of the mesh host the rest of the mesh dials (see api/names.go); - * port is where the agent listens inside its sandbox. - * - * @generated from message sam.v1.AgentIngress - */ -export type AgentIngress = Message<"sam.v1.AgentIngress"> & { - /** - * @generated from field: sam.v1.ServiceType type = 1; - */ - type: ServiceType; - - /** - * @generated from field: string name = 2; - */ - name: string; - - /** - * @generated from field: uint32 port = 3; - */ - port: number; - - /** - * @generated from field: string description = 4; - */ - description: string; -}; - -/** - * Describes the message sam.v1.AgentIngress. - * Use `create(AgentIngressSchema)` to create a new message. - */ -export const AgentIngressSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 33); - -/** - * AgentBundle is everything the platform declares about one agent. Its - * canonical form is a YAML file in the agent's own state directory, so that a - * suspend/resume onto another host carries it with no extra machinery; this - * message is the transport mirror of that file. - * - * @generated from message sam.v1.AgentBundle - */ -export type AgentBundle = Message<"sam.v1.AgentBundle"> & { - /** - * @generated from field: string version = 1; - */ - version: string; - - /** - * Canonical mesh identifier, without the "agent:" prefix. Dot-separated and - * DNS-shaped; see api/agent.go for the rules and why they exist. - * - * @generated from field: string agent_id = 2; - */ - agentId: string; - - /** - * The platform's own identifier, verbatim, kept for audit because the - * translation into agent_id is not always reversible. - * - * @generated from field: string external_id = 3; - */ - externalId: string; - - /** - * Path to the workload credential the platform already issues: a projected - * Kubernetes service-account token, a pod certificate, or an SVID. It is - * verified at admission against the platform's issuer and then translated - * into agent facts, the same way OIDC claims are translated at node - * enrollment. The scheduler needs no mesh credential of its own. - * - * @generated from field: string credential_path = 4; - */ - credentialPath: string; - - /** - * @generated from field: sam.v1.AgentEgress egress = 5; - */ - egress?: AgentEgress | undefined; - - /** - * @generated from field: repeated sam.v1.AgentIngress ingress = 6; - */ - ingress: AgentIngress[]; -}; - -/** - * Describes the message sam.v1.AgentBundle. - * Use `create(AgentBundleSchema)` to create a new message. - */ -export const AgentBundleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 34); - -/** - * AgentAttachRequest admits an agent. It is idempotent on agent_id: resuming - * after a crash or a migration is another Attach, not a distinct operation. - * - * @generated from message sam.v1.AgentAttachRequest - */ -export type AgentAttachRequest = Message<"sam.v1.AgentAttachRequest"> & { - /** - * @generated from field: sam.v1.AgentBundle bundle = 1; - */ - bundle?: AgentBundle | undefined; -}; - -/** - * Describes the message sam.v1.AgentAttachRequest. - * Use `create(AgentAttachRequestSchema)` to create a new message. - */ -export const AgentAttachRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 35); - -/** - * @generated from message sam.v1.AgentAttachResponse - */ -export type AgentAttachResponse = Message<"sam.v1.AgentAttachResponse"> & { - /** - * Sandbox boundary endpoints to wire into the sandbox: named HTTP tunnels - * (CONNECT, connect-udp) for guest to host, and a reverse channel for host - * to guest that is empty when the bundle declares no ingress. - * - * @generated from field: string egress_socket = 1; - */ - egressSocket: string; - - /** - * @generated from field: string ingress_socket = 2; - */ - ingressSocket: string; - - /** - * @generated from field: string error = 3; - */ - error: string; -}; - -/** - * Describes the message sam.v1.AgentAttachResponse. - * Use `create(AgentAttachResponseSchema)` to create a new message. - */ -export const AgentAttachResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 36); - -/** - * AgentDetachRequest stops an agent: ingress is unregistered, channels are - * closed and credentials dropped. It must leave no residual advertisement. - * - * @generated from message sam.v1.AgentDetachRequest - */ -export type AgentDetachRequest = Message<"sam.v1.AgentDetachRequest"> & { - /** - * @generated from field: string agent_id = 1; - */ - agentId: string; -}; - -/** - * Describes the message sam.v1.AgentDetachRequest. - * Use `create(AgentDetachRequestSchema)` to create a new message. - */ -export const AgentDetachRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 37); - -/** - * @generated from message sam.v1.AgentDetachResponse - */ -export type AgentDetachResponse = Message<"sam.v1.AgentDetachResponse"> & { - /** - * @generated from field: bool success = 1; - */ - success: boolean; - - /** - * @generated from field: string error = 2; - */ - error: string; -}; - -/** - * Describes the message sam.v1.AgentDetachResponse. - * Use `create(AgentDetachResponseSchema)` to create a new message. - */ -export const AgentDetachResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 38); - -/** - * AgentRefreshRequest hands in a rotated workload credential. Platforms rotate - * these on their own schedule, which is what bounds how long a stale admission - * stays usable. - * - * @generated from message sam.v1.AgentRefreshRequest - */ -export type AgentRefreshRequest = Message<"sam.v1.AgentRefreshRequest"> & { - /** - * @generated from field: string agent_id = 1; - */ - agentId: string; - - /** - * @generated from field: string credential_path = 2; - */ - credentialPath: string; -}; - -/** - * Describes the message sam.v1.AgentRefreshRequest. - * Use `create(AgentRefreshRequestSchema)` to create a new message. - */ -export const AgentRefreshRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 39); - -/** - * @generated from message sam.v1.AgentRefreshResponse - */ -export type AgentRefreshResponse = Message<"sam.v1.AgentRefreshResponse"> & { - /** - * @generated from field: bool success = 1; - */ - success: boolean; - - /** - * @generated from field: string error = 2; - */ - error: string; - - /** - * @generated from field: google.protobuf.Timestamp expire_time = 3; - */ - expireTime?: Timestamp | undefined; -}; - -/** - * Describes the message sam.v1.AgentRefreshResponse. - * Use `create(AgentRefreshResponseSchema)` to create a new message. - */ -export const AgentRefreshResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 40); - -/** - * AgentStatusRequest reports on one agent, or on all of them when agent_id is - * empty, for a scheduler's reconcile loop. - * - * @generated from message sam.v1.AgentStatusRequest - */ -export type AgentStatusRequest = Message<"sam.v1.AgentStatusRequest"> & { - /** - * @generated from field: string agent_id = 1; - */ - agentId: string; -}; - -/** - * Describes the message sam.v1.AgentStatusRequest. - * Use `create(AgentStatusRequestSchema)` to create a new message. - */ -export const AgentStatusRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 41); - -/** - * @generated from message sam.v1.AgentStatus - */ -export type AgentStatus = Message<"sam.v1.AgentStatus"> & { - /** - * @generated from field: string agent_id = 1; - */ - agentId: string; - - /** - * @generated from field: bool attached = 2; - */ - attached: boolean; - - /** - * @generated from field: repeated sam.v1.AgentIngress ingress = 3; - */ - ingress: AgentIngress[]; - - /** - * @generated from field: google.protobuf.Timestamp credential_expire_time = 4; - */ - credentialExpireTime?: Timestamp | undefined; -}; - -/** - * Describes the message sam.v1.AgentStatus. - * Use `create(AgentStatusSchema)` to create a new message. - */ -export const AgentStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 42); - -/** - * @generated from message sam.v1.AgentStatusResponse - */ -export type AgentStatusResponse = Message<"sam.v1.AgentStatusResponse"> & { - /** - * @generated from field: repeated sam.v1.AgentStatus agents = 1; - */ - agents: AgentStatus[]; - - /** - * @generated from field: string error = 2; - */ - error: string; -}; - -/** - * Describes the message sam.v1.AgentStatusResponse. - * Use `create(AgentStatusResponseSchema)` to create a new message. - */ -export const AgentStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 43); - /** * @generated from message sam.v1.IdentityEvidenceResponse */ @@ -1570,7 +1171,7 @@ export type IdentityEvidenceResponse = Message<"sam.v1.IdentityEvidenceResponse" * Use `create(IdentityEvidenceResponseSchema)` to create a new message. */ export const IdentityEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 44); + messageDesc(file_sam, 31); /** * @generated from message sam.v1.PeerEvidenceResponse @@ -1626,7 +1227,7 @@ export type PeerEvidenceResponse = Message<"sam.v1.PeerEvidenceResponse"> & { * Use `create(PeerEvidenceResponseSchema)` to create a new message. */ export const PeerEvidenceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 45); + messageDesc(file_sam, 32); /** * @generated from message sam.v1.MemberCredential @@ -1691,7 +1292,7 @@ export type MemberCredential = Message<"sam.v1.MemberCredential"> & { * Use `create(MemberCredentialSchema)` to create a new message. */ export const MemberCredentialSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 46); + messageDesc(file_sam, 33); /** * @generated from message sam.v1.TrustedSigningKey @@ -1718,7 +1319,7 @@ export type TrustedSigningKey = Message<"sam.v1.TrustedSigningKey"> & { * Use `create(TrustedSigningKeySchema)` to create a new message. */ export const TrustedSigningKeySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 47); + messageDesc(file_sam, 34); /** * @generated from message sam.v1.OIDCSession @@ -1750,7 +1351,7 @@ export type OIDCSession = Message<"sam.v1.OIDCSession"> & { * Use `create(OIDCSessionSchema)` to create a new message. */ export const OIDCSessionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_sam, 48); + messageDesc(file_sam, 35); /** * @generated from enum sam.v1.EnrollmentStatus @@ -1811,8 +1412,7 @@ export enum ServiceType { * A destination outside the mesh, reached through a node that enforces * policy on it. The service name is the destination hostname, so a grant * reads egress://api.github.com and the request fact - * service("egress", "api.github.com"). Egress names have no .sam.alt form: - * a sandboxed agent connects to the destination name itself. + * service("egress", "api.github.com"). * * @generated from enum value: SERVICE_TYPE_EGRESS = 4; */ diff --git a/sdk/js/src/libp2p-http-node.ts b/sdk/js/src/libp2p-http-node.ts index eb6da96f..4bd98687 100644 --- a/sdk/js/src/libp2p-http-node.ts +++ b/sdk/js/src/libp2p-http-node.ts @@ -24,7 +24,6 @@ import { Duplex } from "node:stream"; import { AUTH_HANDSHAKE_TIMEOUT_MS } from "./auth.ts"; import { HEADER_PEER_ID, - HEADER_SAM_AGENT, HEADER_SAM_BISCUIT, HEADER_SAM_NO_TRAILING_SLASH, admitIngress, @@ -109,7 +108,6 @@ async function serveListener(req: http.IncomingMessage, res: http.ServerResponse // verified peer. req.url = admission.path; delete req.headers[HEADER_SAM_BISCUIT]; - delete req.headers[HEADER_SAM_AGENT]; req.headers[HEADER_PEER_ID] = remotePeer; if (admission.noTrailingSlash) { req.headers[HEADER_SAM_NO_TRAILING_SLASH] = "true"; diff --git a/sdk/js/src/libp2p-http.ts b/sdk/js/src/libp2p-http.ts index df95eee3..3e7bc3f8 100644 --- a/sdk/js/src/libp2p-http.ts +++ b/sdk/js/src/libp2p-http.ts @@ -49,7 +49,6 @@ export const HTTP_PROTOCOL = "/libp2p-http"; /** Headers of the mesh HTTP datapath (api/network.go). */ export const HEADER_SAM_BISCUIT = "x-sam-biscuit"; -export const HEADER_SAM_AGENT = "x-sam-agent"; export const HEADER_PEER_ID = "x-peer-id"; export const HEADER_SAM_NO_TRAILING_SLASH = "x-sam-no-trailing-slash"; @@ -91,8 +90,8 @@ export type NodeRequestListener = (req: any, res: any) => void; /** * This member's agent as other members reach it: `a2a://`, answered by * exactly one of url (an A2A server beside this process), handler or - * listener (in this process). Authorized requests arrive with the biscuit and - * agent headers stripped, X-Peer-Id naming the verified caller and the path + * listener (in this process). Authorized requests arrive with the biscuit + * header stripped, X-Peer-Id naming the verified caller and the path * relative to /a2a/, as sam-node forwards them. The endpoint is not * announced anywhere; a caller reaches it by peer ID. */ @@ -211,7 +210,6 @@ export async function admitIngress(req: IngressRequest, endpoint: A2AEndpoint, o peerId: req.remotePeer, targetService, protocol: HTTP_PROTOCOL, - agent: req.headers.get(HEADER_SAM_AGENT) ?? "", // The path as the backend sees it, decided before authorization so // path() is what policy meant, never the routing prefix. method: req.method, @@ -235,7 +233,7 @@ export async function admitIngress(req: IngressRequest, endpoint: A2AEndpoint, o } /** Headers of the mesh datapath and of the hop itself, not passed on to the agent. */ -const HOP_HEADERS = new Set([HEADER_SAM_BISCUIT, HEADER_SAM_AGENT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length", "keep-alive"]); +const HOP_HEADERS = new Set([HEADER_SAM_BISCUIT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length", "keep-alive"]); /** * The headers the agent sees: the request's own, less the datapath's, with @@ -481,8 +479,6 @@ async function readLimited(body: ReadableStream | null, limit: numbe } export interface HTTPStreamOptions { - /** The agent this request is made for. */ - agent?: string; /** Bounds the whole exchange; without one, the response headers must arrive within a minute and the body is unbounded. */ signal?: AbortSignal; } @@ -532,9 +528,6 @@ async function sendOverStream(conn: Connection, biscuit: Uint8Array, request: Re }); headers.set("host", peerId); headers.set(HEADER_SAM_BISCUIT, toBase64(biscuit)); - if (options.agent) { - headers.set(HEADER_SAM_AGENT, options.agent); - } const body = request.body === null ? new Uint8Array(0) : new Uint8Array(await request.arrayBuffer()); headers.set("content-length", String(body.length)); @@ -571,8 +564,6 @@ export interface HTTPRequestOptions { method?: string; headers?: Record; body?: Uint8Array | string; - /** The agent this request is made for. */ - agent?: string; signal?: AbortSignal; } @@ -598,9 +589,6 @@ export async function httpRequestOverStream( } const request = new Request(meshURL(conn.remotePeer.toString(), targetService, path), init); const streamOptions: HTTPStreamOptions = { signal }; - if (options.agent !== undefined) { - streamOptions.agent = options.agent; - } const response = await fetchOverStream(conn, biscuit, request, streamOptions); const buf = new Uint8Array(await response.arrayBuffer()); if (buf.length > MAX_INGRESS_BODY_BYTES) { diff --git a/sdk/js/src/mcp.ts b/sdk/js/src/mcp.ts index 46b86d54..41024372 100644 --- a/sdk/js/src/mcp.ts +++ b/sdk/js/src/mcp.ts @@ -90,8 +90,6 @@ export class StreamTransport implements Transport { export interface MCPSessionOptions { /** Labels the provider's credential must all carry, e.g. { region: "eu", compliance: "gdpr" }. */ requiredLabels?: Record; - /** The agent this call is made for; attribution beside the token, as in sam-node. */ - agent?: string; signal?: AbortSignal; } diff --git a/sdk/js/src/mesh.test.ts b/sdk/js/src/mesh.test.ts index 18905302..52e770fc 100644 --- a/sdk/js/src/mesh.test.ts +++ b/sdk/js/src/mesh.test.ts @@ -258,10 +258,9 @@ test("enroll reads a workload identity token from jwtPath", async () => { test("authFrame is the AuthFrame protobuf with this member's biscuit", async () => { const cp = fakeControlPlane(); const mesh = await AgentMesh.enroll({ controlPlaneUrl: "http://127.0.0.1:1", bootstrapToken: "sbt_secret", fetch: cp.fetch }); - const frame = fromBinary(AuthFrameSchema, mesh.authFrame("mcp://calculator", "agent:acme.example:bot")); + const frame = fromBinary(AuthFrameSchema, mesh.authFrame("mcp://calculator")); assert.deepEqual(frame.biscuit, text("biscuit-1")); assert.equal(frame.targetService, "mcp://calculator"); - assert.equal(frame.agent, "agent:acme.example:bot"); const resp = decodeAuthResponse(toBinary(AuthResponseSchema, create(AuthResponseSchema, { success: false, error: "denied" }))); assert.equal(resp.success, false); diff --git a/sdk/js/src/mesh.ts b/sdk/js/src/mesh.ts index c191c237..586d0759 100644 --- a/sdk/js/src/mesh.ts +++ b/sdk/js/src/mesh.ts @@ -323,10 +323,10 @@ export class AgentMesh { /** * The frame that opens every stream to a peer: this member's biscuit plus - * the service it wants (e.g. "mcp://calculator") and the agent it speaks for. + * the service it wants (e.g. "mcp://calculator"). */ - authFrame(targetService = "", agent = ""): Uint8Array { - return encodeAuthFrame(this.#credential.biscuit, targetService, agent); + authFrame(targetService = ""): Uint8Array { + return encodeAuthFrame(this.#credential.biscuit, targetService); } /** diff --git a/sdk/js/src/session.ts b/sdk/js/src/session.ts index 009a6044..235d772b 100644 --- a/sdk/js/src/session.ts +++ b/sdk/js/src/session.ts @@ -478,7 +478,7 @@ export class MeshSession { */ async openMCP(peer: Peer, targetService: string, options: MCPSessionOptions = {}): Promise { const conn = await this.connect(peer, options.signal); - return openMCPSession(conn, this.mesh.authFrame(targetService, options.agent ?? ""), this.mesh.credential.controlPlaneKeys, options, this.#egressRequireLabels); + return openMCPSession(conn, this.mesh.authFrame(targetService), this.mesh.credential.controlPlaneKeys, options, this.#egressRequireLabels); } /** Lists the tools a provider serves for a service. */ @@ -668,15 +668,12 @@ export class MeshSession { * is carried to that peer over /libp2p-http with this member's credential. * Response bodies stream, so message/stream works. See MeshSession.meshURL. */ - fetch(options: { agent?: string } = {}): typeof fetch { + fetch(): typeof fetch { return async (input, init) => { const request = new Request(input, init); const { peerId } = splitMeshURL(new URL(request.url)); const conn = await this.#egressConnection(peerId, request.signal); - const streamOptions: { agent?: string; signal?: AbortSignal } = {}; - if (options.agent !== undefined) { - streamOptions.agent = options.agent; - } + const streamOptions: { signal?: AbortSignal } = {}; if (init?.signal !== undefined && init.signal !== null) { streamOptions.signal = init.signal; } diff --git a/sdk/python/src/agent_mesh/_gen/datalog.json b/sdk/python/src/agent_mesh/_gen/datalog.json index ff958550..b8aa9825 100644 --- a/sdk/python/src/agent_mesh/_gen/datalog.json +++ b/sdk/python/src/agent_mesh/_gen/datalog.json @@ -28,13 +28,6 @@ "granted_service_all($t) <- service($t, $n), http_granted_service_all($t), http_method_ok($t, \"*\"), http_path_ok($t, \"*\")", "granted_service_all_types(true) <- service($t, $n), http_granted_service_all_types(true), http_method_ok(\"*\", \"*\"), http_path_ok(\"*\", \"*\")" ], - "agent_rules": [ - "agent_authorized(true) <- agent($a), granted_agent_exact($a)", - "agent_authorized(true) <- agent($a), granted_agent_set($set), $set.contains($a)", - "agent_authorized(true) <- agent($a), granted_agent_prefix($prefix), $a.starts_with($prefix)", - "agent_authorized(true) <- agent($a), granted_agent_suffix($suffix), $a.ends_with($suffix)", - "agent_authorized(true) <- agent($a), granted_agent_all(true)" - ], "target_fact_rules": [ "target_fact(\"email\", $val) <- email($val)", "target_fact(\"group\", $val) <- group($val)", @@ -44,12 +37,10 @@ ], "replay_check": "check if client_peer_id($id), connection_peer_id($id)", "target_check": "check if allow_network_target($fact, $val) or target_unrestricted(true)", - "agent_check": "check if agent_authorized(true)", "time_check": "check if time($time), expiration($exp), $time <= $exp", "allow_if_true": "allow if true", "fact_service": "service", "fact_connection_peer_id": "connection_peer_id", - "fact_agent": "agent", "fact_method": "method", "fact_path": "path", "fact_time": "time", diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.py b/sdk/python/src/agent_mesh/_proto/sam_pb2.py index a0ac4432..5eb08989 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.py +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.py @@ -14,7 +14,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"C\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\x12\r\n\x05\x61gent\x18\x03 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xb6\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_agents\x18\x05 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x06 \x03(\t\x12\x1f\n\x04http\x18\x07 \x03(\x0b\x32\x11.sam.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\\\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x85\x01\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\x12)\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"M\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x03 \x03(\tJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\x05rolesR\x08\x62indings\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"F\n\x19\x45gressAssignmentsResponse\x12)\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"^\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"R\n\x0b\x41gentSecret\x12\x0c\n\x04host\x18\x01 \x01(\t\x12\x0c\n\x04kind\x18\x02 \x01(\t\x12\x13\n\x0bheader_name\x18\x03 \x01(\t\x12\x12\n\nvalue_path\x18\x04 \x01(\t\"B\n\x0b\x41gentEgress\x12\r\n\x05\x61llow\x18\x01 \x03(\t\x12$\n\x07secrets\x18\x02 \x03(\x0b\x32\x13.sam.v1.AgentSecret\"b\n\x0c\x41gentIngress\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x13\n\x0b\x64\x65scription\x18\x04 \x01(\t\"\xaa\x01\n\x0b\x41gentBundle\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x10\n\x08\x61gent_id\x18\x02 \x01(\t\x12\x13\n\x0b\x65xternal_id\x18\x03 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x04 \x01(\t\x12#\n\x06\x65gress\x18\x05 \x01(\x0b\x32\x13.sam.v1.AgentEgress\x12%\n\x07ingress\x18\x06 \x03(\x0b\x32\x14.sam.v1.AgentIngress\"9\n\x12\x41gentAttachRequest\x12#\n\x06\x62undle\x18\x01 \x01(\x0b\x32\x13.sam.v1.AgentBundle\"S\n\x13\x41gentAttachResponse\x12\x15\n\regress_socket\x18\x01 \x01(\t\x12\x16\n\x0eingress_socket\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\"&\n\x12\x41gentDetachRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"5\n\x13\x41gentDetachResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"@\n\x13\x41gentRefreshRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x17\n\x0f\x63redential_path\x18\x02 \x01(\t\"g\n\x14\x41gentRefreshResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"&\n\x12\x41gentStatusRequest\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\"\x94\x01\n\x0b\x41gentStatus\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x10\n\x08\x61ttached\x18\x02 \x01(\x08\x12%\n\x07ingress\x18\x03 \x03(\x0b\x32\x14.sam.v1.AgentIngress\x12:\n\x16\x63redential_expire_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"I\n\x13\x41gentStatusResponse\x12#\n\x06\x61gents\x18\x01 \x03(\x0b\x32\x13.sam.v1.AgentStatus\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tsam.proto\x12\x06sam.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"4\n\tAuthFrame\x12\x0f\n\x07\x62iscuit\x18\x01 \x01(\x0c\x12\x16\n\x0etarget_service\x18\x02 \x01(\t\"?\n\x0c\x41uthResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\"\xd6\x01\n\tMeshEvent\x12$\n\x04type\x18\x01 \x01(\x0e\x32\x16.sam.v1.MeshEvent.Type\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12.\n\nevent_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0enew_public_key\x18\x04 \x01(\x0c\x12\x11\n\tsignature\x18\x05 \x01(\x0c\"7\n\x04Type\x12\n\n\x06\x42\x41NNED\x10\x00\x12\x10\n\x0cKEY_ROTATION\x10\x01\x12\x11\n\rPOLICY_UPDATE\x10\x02\"\xf3\x01\n\rEnrollRequest\x12\x0b\n\x03jwt\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12\x31\n\x06labels\x18\x05 \x03(\x0b\x32!.sam.v1.EnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xab\x01\n\x0e\x45nrollResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12\x15\n\rerror_message\x18\x02 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x03 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x91\x02\n\x16\x42ootstrapEnrollRequest\x12\x17\n\x0f\x62ootstrap_token\x18\x01 \x01(\t\x12\x0f\n\x07peer_id\x18\x02 \x01(\t\x12\x12\n\npublic_key\x18\x03 \x01(\x0c\x12\x16\n\x0erequested_role\x18\x04 \x01(\t\x12:\n\x06labels\x18\x05 \x03(\x0b\x32*.sam.v1.BootstrapEnrollRequest.LabelsEntry\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xfd\x01\n\x17\x42ootstrapEnrollResponse\x12(\n\x06status\x18\x01 \x01(\x0e\x32\x18.sam.v1.EnrollmentStatus\x12\x15\n\rbiscuit_token\x18\x02 \x01(\x0c\x12\x1d\n\x15poll_interval_seconds\x18\x03 \x01(\x05\x12\x15\n\rerror_message\x18\x04 \x01(\t\x12 \n\x18\x63ontrol_plane_public_key\x18\x05 \x01(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12/\n\x0b\x65xpire_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"S\n\x0bServiceInfo\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\"{\n\x0e\x43ommandBackend\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12,\n\x03\x65nv\x18\x02 \x03(\x0b\x32\x1f.sam.v1.CommandBackend.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x8a\x01\n\x16RegisterServiceRequest\x12$\n\x07service\x18\x01 \x01(\x0b\x32\x13.sam.v1.ServiceInfo\x12\x14\n\ntarget_url\x18\x02 \x01(\tH\x00\x12)\n\x07\x63ommand\x18\x03 \x01(\x0b\x32\x16.sam.v1.CommandBackendH\x00\x42\t\n\x07\x62\x61\x63kend\"i\n\x12\x44iscoveredProvider\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x17\n\x0flocal_proxy_url\x18\x02 \x01(\t\x12\x10\n\x08srv_name\x18\x03 \x01(\t\x12\x17\n\x0fsrv_description\x18\x04 \x01(\t\"\xb2\x02\n\x0fServiceAnnounce\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12!\n\x04type\x18\x02 \x01(\x0e\x32\x13.sam.v1.ServiceType\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x0c\n\x04keys\x18\x04 \x03(\t\x12\x33\n\x06labels\x18\x05 \x03(\x0b\x32#.sam.v1.ServiceAnnounce.LabelsEntry\x12\x17\n\x0f\x61\x63tive_requests\x18\x06 \x01(\r\x12\x17\n\x0flatency_ewma_ms\x18\x07 \x01(\x01\x12\x31\n\rannounce_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\x18\x43ontrolPlaneInfoResponse\x12\x13\n\x0boidc_issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x18\n\x10router_addresses\x18\x04 \x03(\t\x12\x17\n\x0f\x62\x61nned_peer_ids\x18\x05 \x03(\t\"\xac\x01\n\x12RouterLeaseRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x11\n\taddresses\x18\x02 \x03(\t\x12\x0f\n\x07\x62iscuit\x18\x03 \x01(\x0c\x12\x17\n\x0f\x63onnected_peers\x18\x04 \x03(\t\x12\x10\n\x08\x64ht_size\x18\x05 \x01(\x05\x12\x19\n\x11\x63hallenge_unix_ms\x18\x06 \x01(\x03\x12\x1b\n\x13\x63hallenge_signature\x18\x07 \x01(\x0c\"f\n\x13RouterLeaseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x9e\x01\n\nPolicyRole\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0f\x61llowed_targets\x18\x02 \x03(\t\x12\x18\n\x10\x61llowed_services\x18\x03 \x03(\t\x12\x16\n\x0e\x63ustom_datalog\x18\x04 \x03(\t\x12\x16\n\x0e\x61llowed_labels\x18\x05 \x03(\t\x12\x1f\n\x04http\x18\x06 \x03(\x0b\x32\x11.sam.v1.HTTPGrant\"<\n\tHTTPGrant\x12\x0f\n\x07service\x18\x01 \x01(\t\x12\x0f\n\x07methods\x18\x02 \x03(\t\x12\r\n\x05paths\x18\x03 \x03(\t\"\\\n\x11\x45gressDestination\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntarget_url\x18\x02 \x01(\t\x12\x12\n\ncredential\x18\x03 \x01(\t\x12\x11\n\tserved_by\x18\x04 \x03(\t\".\n\rPolicyBinding\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0f\n\x07members\x18\x02 \x03(\t\"\x85\x01\n\x0cPolicyConfig\x12!\n\x05roles\x18\x01 \x03(\x0b\x32\x12.sam.v1.PolicyRole\x12\'\n\x08\x62indings\x18\x02 \x03(\x0b\x32\x15.sam.v1.PolicyBinding\x12)\n\x06\x65gress\x18\x03 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"\x18\n\x16PolicyConfigGetRequest\"0\n\x17PolicyConfigGetResponse\x12\x15\n\rdatalog_rules\x18\x01 \x03(\t\"<\n\x1aPolicyConfigUpdateResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\x1a\n\x18\x45gressAssignmentsRequest\"F\n\x19\x45gressAssignmentsResponse\x12)\n\x06\x65gress\x18\x01 \x03(\x0b\x32\x19.sam.v1.EgressDestination\"f\n\x0cKeysResponse\x12\x13\n\x0bpublic_keys\x18\x01 \x03(\x0c\x12-\n\tsign_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x12\n\nsignatures\x18\x03 \x03(\x0c\"^\n\x13TokenRefreshRequest\x12\x1b\n\x13\x63hallenge_signature\x18\x01 \x01(\x0c\x12\x19\n\x11\x63hallenge_unix_ms\x18\x02 \x01(\x03\x12\x0f\n\x07peer_id\x18\x03 \x01(\t\"u\n\x14TokenRefreshResponse\x12\x15\n\rbiscuit_token\x18\x01 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x15\n\rerror_message\x18\x03 \x01(\t\":\n\x11NodeCatalogReport\x12%\n\x08services\x18\x01 \x03(\x0b\x32\x13.sam.v1.ServiceInfo\"%\n\x12TokenRevokeRequest\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\"5\n\x13TokenRevokeResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"\xe4\x01\n\x18IdentityEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x37\n\x13\x62iscuit_expire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x19\n\x11\x63ontrol_plane_url\x18\x04 \x01(\t\x12\"\n\x1atrusted_control_plane_keys\x18\x05 \x03(\x0c\x12.\n\ncheck_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xc0\x02\n\x14PeerEvidenceResponse\x12\x0f\n\x07peer_id\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12\x15\n\rverifying_key\x18\x03 \x01(\x0c\x12\r\n\x05roles\x18\x04 \x03(\t\x12\x38\n\x06labels\x18\x05 \x03(\x0b\x32(.sam.v1.PeerEvidenceResponse.LabelsEntry\x12/\n\x0b\x65xpire_time\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x16\n\x0erevocation_ids\x18\x07 \x03(\t\x12.\n\ncheck_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x80\x02\n\x10MemberCredential\x12\x19\n\x11\x63ontrol_plane_url\x18\x01 \x01(\t\x12\x0f\n\x07\x62iscuit\x18\x02 \x01(\x0c\x12/\n\x0b\x65xpire_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0ctrusted_keys\x18\x04 \x03(\x0b\x32\x19.sam.v1.TrustedSigningKey\x12\x19\n\x11issued_under_keys\x18\x05 \x03(\x0c\x12\x18\n\x10router_addresses\x18\x06 \x03(\t\x12)\n\x0coidc_session\x18\x07 \x01(\x0b\x32\x13.sam.v1.OIDCSession\"Y\n\x11TrustedSigningKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x30\n\x0creceive_time\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"Y\n\x0bOIDCSession\x12\x0e\n\x06issuer\x18\x01 \x01(\t\x12\x11\n\tclient_id\x18\x02 \x01(\t\x12\x10\n\x08\x61udience\x18\x03 \x01(\t\x12\x15\n\rrefresh_token\x18\x04 \x01(\t*\x94\x01\n\x10\x45nrollmentStatus\x12!\n\x1d\x45NROLLMENT_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n\x19\x45NROLLMENT_STATUS_PENDING\x10\x01\x12\x1e\n\x1a\x45NROLLMENT_STATUS_APPROVED\x10\x02\x12\x1e\n\x1a\x45NROLLMENT_STATUS_REJECTED\x10\x03*\x8c\x01\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10SERVICE_TYPE_MCP\x10\x01\x12\x1a\n\x16SERVICE_TYPE_INFERENCE\x10\x02\x12\x14\n\x10SERVICE_TYPE_A2A\x10\x03\x12\x17\n\x13SERVICE_TYPE_EGRESS\x10\x04\x42\x1bZ\x19github.com/google/sam/apib\x06proto3') _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'sam_pb2', globals()) @@ -32,118 +32,92 @@ _SERVICEANNOUNCE_LABELSENTRY._serialized_options = b'8\001' _PEEREVIDENCERESPONSE_LABELSENTRY._options = None _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_options = b'8\001' - _ENROLLMENTSTATUS._serialized_start=5903 - _ENROLLMENTSTATUS._serialized_end=6051 - _SERVICETYPE._serialized_start=6054 - _SERVICETYPE._serialized_end=6194 + _ENROLLMENTSTATUS._serialized_start=4734 + _ENROLLMENTSTATUS._serialized_end=4882 + _SERVICETYPE._serialized_start=4885 + _SERVICETYPE._serialized_end=5025 _AUTHFRAME._serialized_start=54 - _AUTHFRAME._serialized_end=121 - _AUTHRESPONSE._serialized_start=123 - _AUTHRESPONSE._serialized_end=186 - _MESHEVENT._serialized_start=189 - _MESHEVENT._serialized_end=403 - _MESHEVENT_TYPE._serialized_start=348 - _MESHEVENT_TYPE._serialized_end=403 - _ENROLLREQUEST._serialized_start=406 - _ENROLLREQUEST._serialized_end=649 - _ENROLLREQUEST_LABELSENTRY._serialized_start=604 - _ENROLLREQUEST_LABELSENTRY._serialized_end=649 - _ENROLLRESPONSE._serialized_start=652 - _ENROLLRESPONSE._serialized_end=823 - _BOOTSTRAPENROLLREQUEST._serialized_start=826 - _BOOTSTRAPENROLLREQUEST._serialized_end=1099 - _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_start=604 - _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_end=649 - _BOOTSTRAPENROLLRESPONSE._serialized_start=1102 - _BOOTSTRAPENROLLRESPONSE._serialized_end=1355 - _SERVICEINFO._serialized_start=1357 - _SERVICEINFO._serialized_end=1440 - _COMMANDBACKEND._serialized_start=1442 - _COMMANDBACKEND._serialized_end=1565 - _COMMANDBACKEND_ENVENTRY._serialized_start=1523 - _COMMANDBACKEND_ENVENTRY._serialized_end=1565 - _REGISTERSERVICEREQUEST._serialized_start=1568 - _REGISTERSERVICEREQUEST._serialized_end=1706 - _DISCOVEREDPROVIDER._serialized_start=1708 - _DISCOVEREDPROVIDER._serialized_end=1813 - _SERVICEANNOUNCE._serialized_start=1816 - _SERVICEANNOUNCE._serialized_end=2122 - _SERVICEANNOUNCE_LABELSENTRY._serialized_start=604 - _SERVICEANNOUNCE_LABELSENTRY._serialized_end=649 - _CONTROLPLANEINFORESPONSE._serialized_start=2125 - _CONTROLPLANEINFORESPONSE._serialized_end=2260 - _ROUTERLEASEREQUEST._serialized_start=2263 - _ROUTERLEASEREQUEST._serialized_end=2435 - _ROUTERLEASERESPONSE._serialized_start=2437 - _ROUTERLEASERESPONSE._serialized_end=2539 - _POLICYROLE._serialized_start=2542 - _POLICYROLE._serialized_end=2724 - _HTTPGRANT._serialized_start=2726 - _HTTPGRANT._serialized_end=2786 - _EGRESSDESTINATION._serialized_start=2788 - _EGRESSDESTINATION._serialized_end=2880 - _POLICYBINDING._serialized_start=2882 - _POLICYBINDING._serialized_end=2928 - _POLICYCONFIG._serialized_start=2931 - _POLICYCONFIG._serialized_end=3064 - _POLICYCONFIGGETREQUEST._serialized_start=3066 - _POLICYCONFIGGETREQUEST._serialized_end=3090 - _POLICYCONFIGGETRESPONSE._serialized_start=3092 - _POLICYCONFIGGETRESPONSE._serialized_end=3169 - _POLICYCONFIGUPDATERESPONSE._serialized_start=3171 - _POLICYCONFIGUPDATERESPONSE._serialized_end=3231 - _EGRESSASSIGNMENTSREQUEST._serialized_start=3233 - _EGRESSASSIGNMENTSREQUEST._serialized_end=3259 - _EGRESSASSIGNMENTSRESPONSE._serialized_start=3261 - _EGRESSASSIGNMENTSRESPONSE._serialized_end=3331 - _KEYSRESPONSE._serialized_start=3333 - _KEYSRESPONSE._serialized_end=3435 - _TOKENREFRESHREQUEST._serialized_start=3437 - _TOKENREFRESHREQUEST._serialized_end=3531 - _TOKENREFRESHRESPONSE._serialized_start=3533 - _TOKENREFRESHRESPONSE._serialized_end=3650 - _NODECATALOGREPORT._serialized_start=3652 - _NODECATALOGREPORT._serialized_end=3710 - _TOKENREVOKEREQUEST._serialized_start=3712 - _TOKENREVOKEREQUEST._serialized_end=3749 - _TOKENREVOKERESPONSE._serialized_start=3751 - _TOKENREVOKERESPONSE._serialized_end=3804 - _AGENTSECRET._serialized_start=3806 - _AGENTSECRET._serialized_end=3888 - _AGENTEGRESS._serialized_start=3890 - _AGENTEGRESS._serialized_end=3956 - _AGENTINGRESS._serialized_start=3958 - _AGENTINGRESS._serialized_end=4056 - _AGENTBUNDLE._serialized_start=4059 - _AGENTBUNDLE._serialized_end=4229 - _AGENTATTACHREQUEST._serialized_start=4231 - _AGENTATTACHREQUEST._serialized_end=4288 - _AGENTATTACHRESPONSE._serialized_start=4290 - _AGENTATTACHRESPONSE._serialized_end=4373 - _AGENTDETACHREQUEST._serialized_start=4375 - _AGENTDETACHREQUEST._serialized_end=4413 - _AGENTDETACHRESPONSE._serialized_start=4415 - _AGENTDETACHRESPONSE._serialized_end=4468 - _AGENTREFRESHREQUEST._serialized_start=4470 - _AGENTREFRESHREQUEST._serialized_end=4534 - _AGENTREFRESHRESPONSE._serialized_start=4536 - _AGENTREFRESHRESPONSE._serialized_end=4639 - _AGENTSTATUSREQUEST._serialized_start=4641 - _AGENTSTATUSREQUEST._serialized_end=4679 - _AGENTSTATUS._serialized_start=4682 - _AGENTSTATUS._serialized_end=4830 - _AGENTSTATUSRESPONSE._serialized_start=4832 - _AGENTSTATUSRESPONSE._serialized_end=4905 - _IDENTITYEVIDENCERESPONSE._serialized_start=4908 - _IDENTITYEVIDENCERESPONSE._serialized_end=5136 - _PEEREVIDENCERESPONSE._serialized_start=5139 - _PEEREVIDENCERESPONSE._serialized_end=5459 - _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=604 - _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=649 - _MEMBERCREDENTIAL._serialized_start=5462 - _MEMBERCREDENTIAL._serialized_end=5718 - _TRUSTEDSIGNINGKEY._serialized_start=5720 - _TRUSTEDSIGNINGKEY._serialized_end=5809 - _OIDCSESSION._serialized_start=5811 - _OIDCSESSION._serialized_end=5900 + _AUTHFRAME._serialized_end=106 + _AUTHRESPONSE._serialized_start=108 + _AUTHRESPONSE._serialized_end=171 + _MESHEVENT._serialized_start=174 + _MESHEVENT._serialized_end=388 + _MESHEVENT_TYPE._serialized_start=333 + _MESHEVENT_TYPE._serialized_end=388 + _ENROLLREQUEST._serialized_start=391 + _ENROLLREQUEST._serialized_end=634 + _ENROLLREQUEST_LABELSENTRY._serialized_start=589 + _ENROLLREQUEST_LABELSENTRY._serialized_end=634 + _ENROLLRESPONSE._serialized_start=637 + _ENROLLRESPONSE._serialized_end=808 + _BOOTSTRAPENROLLREQUEST._serialized_start=811 + _BOOTSTRAPENROLLREQUEST._serialized_end=1084 + _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_start=589 + _BOOTSTRAPENROLLREQUEST_LABELSENTRY._serialized_end=634 + _BOOTSTRAPENROLLRESPONSE._serialized_start=1087 + _BOOTSTRAPENROLLRESPONSE._serialized_end=1340 + _SERVICEINFO._serialized_start=1342 + _SERVICEINFO._serialized_end=1425 + _COMMANDBACKEND._serialized_start=1427 + _COMMANDBACKEND._serialized_end=1550 + _COMMANDBACKEND_ENVENTRY._serialized_start=1508 + _COMMANDBACKEND_ENVENTRY._serialized_end=1550 + _REGISTERSERVICEREQUEST._serialized_start=1553 + _REGISTERSERVICEREQUEST._serialized_end=1691 + _DISCOVEREDPROVIDER._serialized_start=1693 + _DISCOVEREDPROVIDER._serialized_end=1798 + _SERVICEANNOUNCE._serialized_start=1801 + _SERVICEANNOUNCE._serialized_end=2107 + _SERVICEANNOUNCE_LABELSENTRY._serialized_start=589 + _SERVICEANNOUNCE_LABELSENTRY._serialized_end=634 + _CONTROLPLANEINFORESPONSE._serialized_start=2110 + _CONTROLPLANEINFORESPONSE._serialized_end=2245 + _ROUTERLEASEREQUEST._serialized_start=2248 + _ROUTERLEASEREQUEST._serialized_end=2420 + _ROUTERLEASERESPONSE._serialized_start=2422 + _ROUTERLEASERESPONSE._serialized_end=2524 + _POLICYROLE._serialized_start=2527 + _POLICYROLE._serialized_end=2685 + _HTTPGRANT._serialized_start=2687 + _HTTPGRANT._serialized_end=2747 + _EGRESSDESTINATION._serialized_start=2749 + _EGRESSDESTINATION._serialized_end=2841 + _POLICYBINDING._serialized_start=2843 + _POLICYBINDING._serialized_end=2889 + _POLICYCONFIG._serialized_start=2892 + _POLICYCONFIG._serialized_end=3025 + _POLICYCONFIGGETREQUEST._serialized_start=3027 + _POLICYCONFIGGETREQUEST._serialized_end=3051 + _POLICYCONFIGGETRESPONSE._serialized_start=3053 + _POLICYCONFIGGETRESPONSE._serialized_end=3101 + _POLICYCONFIGUPDATERESPONSE._serialized_start=3103 + _POLICYCONFIGUPDATERESPONSE._serialized_end=3163 + _EGRESSASSIGNMENTSREQUEST._serialized_start=3165 + _EGRESSASSIGNMENTSREQUEST._serialized_end=3191 + _EGRESSASSIGNMENTSRESPONSE._serialized_start=3193 + _EGRESSASSIGNMENTSRESPONSE._serialized_end=3263 + _KEYSRESPONSE._serialized_start=3265 + _KEYSRESPONSE._serialized_end=3367 + _TOKENREFRESHREQUEST._serialized_start=3369 + _TOKENREFRESHREQUEST._serialized_end=3463 + _TOKENREFRESHRESPONSE._serialized_start=3465 + _TOKENREFRESHRESPONSE._serialized_end=3582 + _NODECATALOGREPORT._serialized_start=3584 + _NODECATALOGREPORT._serialized_end=3642 + _TOKENREVOKEREQUEST._serialized_start=3644 + _TOKENREVOKEREQUEST._serialized_end=3681 + _TOKENREVOKERESPONSE._serialized_start=3683 + _TOKENREVOKERESPONSE._serialized_end=3736 + _IDENTITYEVIDENCERESPONSE._serialized_start=3739 + _IDENTITYEVIDENCERESPONSE._serialized_end=3967 + _PEEREVIDENCERESPONSE._serialized_start=3970 + _PEEREVIDENCERESPONSE._serialized_end=4290 + _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_start=589 + _PEEREVIDENCERESPONSE_LABELSENTRY._serialized_end=634 + _MEMBERCREDENTIAL._serialized_start=4293 + _MEMBERCREDENTIAL._serialized_end=4549 + _TRUSTEDSIGNINGKEY._serialized_start=4551 + _TRUSTEDSIGNINGKEY._serialized_end=4640 + _OIDCSESSION._serialized_start=4642 + _OIDCSESSION._serialized_end=4731 # @@protoc_insertion_point(module_scope) diff --git a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi index b172eb28..9b63fce6 100644 --- a/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi +++ b/sdk/python/src/agent_mesh/_proto/sam_pb2.pyi @@ -16,137 +16,13 @@ SERVICE_TYPE_INFERENCE: ServiceType SERVICE_TYPE_MCP: ServiceType SERVICE_TYPE_UNSPECIFIED: ServiceType -class AgentAttachRequest(_message.Message): - __slots__ = ["bundle"] - BUNDLE_FIELD_NUMBER: _ClassVar[int] - bundle: AgentBundle - def __init__(self, bundle: _Optional[_Union[AgentBundle, _Mapping]] = ...) -> None: ... - -class AgentAttachResponse(_message.Message): - __slots__ = ["egress_socket", "error", "ingress_socket"] - EGRESS_SOCKET_FIELD_NUMBER: _ClassVar[int] - ERROR_FIELD_NUMBER: _ClassVar[int] - INGRESS_SOCKET_FIELD_NUMBER: _ClassVar[int] - egress_socket: str - error: str - ingress_socket: str - def __init__(self, egress_socket: _Optional[str] = ..., ingress_socket: _Optional[str] = ..., error: _Optional[str] = ...) -> None: ... - -class AgentBundle(_message.Message): - __slots__ = ["agent_id", "credential_path", "egress", "external_id", "ingress", "version"] - AGENT_ID_FIELD_NUMBER: _ClassVar[int] - CREDENTIAL_PATH_FIELD_NUMBER: _ClassVar[int] - EGRESS_FIELD_NUMBER: _ClassVar[int] - EXTERNAL_ID_FIELD_NUMBER: _ClassVar[int] - INGRESS_FIELD_NUMBER: _ClassVar[int] - VERSION_FIELD_NUMBER: _ClassVar[int] - agent_id: str - credential_path: str - egress: AgentEgress - external_id: str - ingress: _containers.RepeatedCompositeFieldContainer[AgentIngress] - version: str - def __init__(self, version: _Optional[str] = ..., agent_id: _Optional[str] = ..., external_id: _Optional[str] = ..., credential_path: _Optional[str] = ..., egress: _Optional[_Union[AgentEgress, _Mapping]] = ..., ingress: _Optional[_Iterable[_Union[AgentIngress, _Mapping]]] = ...) -> None: ... - -class AgentDetachRequest(_message.Message): - __slots__ = ["agent_id"] - AGENT_ID_FIELD_NUMBER: _ClassVar[int] - agent_id: str - def __init__(self, agent_id: _Optional[str] = ...) -> None: ... - -class AgentDetachResponse(_message.Message): - __slots__ = ["error", "success"] - ERROR_FIELD_NUMBER: _ClassVar[int] - SUCCESS_FIELD_NUMBER: _ClassVar[int] - error: str - success: bool - def __init__(self, success: bool = ..., error: _Optional[str] = ...) -> None: ... - -class AgentEgress(_message.Message): - __slots__ = ["allow", "secrets"] - ALLOW_FIELD_NUMBER: _ClassVar[int] - SECRETS_FIELD_NUMBER: _ClassVar[int] - allow: _containers.RepeatedScalarFieldContainer[str] - secrets: _containers.RepeatedCompositeFieldContainer[AgentSecret] - def __init__(self, allow: _Optional[_Iterable[str]] = ..., secrets: _Optional[_Iterable[_Union[AgentSecret, _Mapping]]] = ...) -> None: ... - -class AgentIngress(_message.Message): - __slots__ = ["description", "name", "port", "type"] - DESCRIPTION_FIELD_NUMBER: _ClassVar[int] - NAME_FIELD_NUMBER: _ClassVar[int] - PORT_FIELD_NUMBER: _ClassVar[int] - TYPE_FIELD_NUMBER: _ClassVar[int] - description: str - name: str - port: int - type: ServiceType - def __init__(self, type: _Optional[_Union[ServiceType, str]] = ..., name: _Optional[str] = ..., port: _Optional[int] = ..., description: _Optional[str] = ...) -> None: ... - -class AgentRefreshRequest(_message.Message): - __slots__ = ["agent_id", "credential_path"] - AGENT_ID_FIELD_NUMBER: _ClassVar[int] - CREDENTIAL_PATH_FIELD_NUMBER: _ClassVar[int] - agent_id: str - credential_path: str - def __init__(self, agent_id: _Optional[str] = ..., credential_path: _Optional[str] = ...) -> None: ... - -class AgentRefreshResponse(_message.Message): - __slots__ = ["error", "expire_time", "success"] - ERROR_FIELD_NUMBER: _ClassVar[int] - EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] - SUCCESS_FIELD_NUMBER: _ClassVar[int] - error: str - expire_time: _timestamp_pb2.Timestamp - success: bool - def __init__(self, success: bool = ..., error: _Optional[str] = ..., expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... - -class AgentSecret(_message.Message): - __slots__ = ["header_name", "host", "kind", "value_path"] - HEADER_NAME_FIELD_NUMBER: _ClassVar[int] - HOST_FIELD_NUMBER: _ClassVar[int] - KIND_FIELD_NUMBER: _ClassVar[int] - VALUE_PATH_FIELD_NUMBER: _ClassVar[int] - header_name: str - host: str - kind: str - value_path: str - def __init__(self, host: _Optional[str] = ..., kind: _Optional[str] = ..., header_name: _Optional[str] = ..., value_path: _Optional[str] = ...) -> None: ... - -class AgentStatus(_message.Message): - __slots__ = ["agent_id", "attached", "credential_expire_time", "ingress"] - AGENT_ID_FIELD_NUMBER: _ClassVar[int] - ATTACHED_FIELD_NUMBER: _ClassVar[int] - CREDENTIAL_EXPIRE_TIME_FIELD_NUMBER: _ClassVar[int] - INGRESS_FIELD_NUMBER: _ClassVar[int] - agent_id: str - attached: bool - credential_expire_time: _timestamp_pb2.Timestamp - ingress: _containers.RepeatedCompositeFieldContainer[AgentIngress] - def __init__(self, agent_id: _Optional[str] = ..., attached: bool = ..., ingress: _Optional[_Iterable[_Union[AgentIngress, _Mapping]]] = ..., credential_expire_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... - -class AgentStatusRequest(_message.Message): - __slots__ = ["agent_id"] - AGENT_ID_FIELD_NUMBER: _ClassVar[int] - agent_id: str - def __init__(self, agent_id: _Optional[str] = ...) -> None: ... - -class AgentStatusResponse(_message.Message): - __slots__ = ["agents", "error"] - AGENTS_FIELD_NUMBER: _ClassVar[int] - ERROR_FIELD_NUMBER: _ClassVar[int] - agents: _containers.RepeatedCompositeFieldContainer[AgentStatus] - error: str - def __init__(self, agents: _Optional[_Iterable[_Union[AgentStatus, _Mapping]]] = ..., error: _Optional[str] = ...) -> None: ... - class AuthFrame(_message.Message): - __slots__ = ["agent", "biscuit", "target_service"] - AGENT_FIELD_NUMBER: _ClassVar[int] + __slots__ = ["biscuit", "target_service"] BISCUIT_FIELD_NUMBER: _ClassVar[int] TARGET_SERVICE_FIELD_NUMBER: _ClassVar[int] - agent: str biscuit: bytes target_service: str - def __init__(self, biscuit: _Optional[bytes] = ..., target_service: _Optional[str] = ..., agent: _Optional[str] = ...) -> None: ... + def __init__(self, biscuit: _Optional[bytes] = ..., target_service: _Optional[str] = ...) -> None: ... class AuthResponse(_message.Message): __slots__ = ["biscuit", "error", "success"] @@ -458,22 +334,20 @@ class PolicyConfigUpdateResponse(_message.Message): def __init__(self, success: bool = ..., error: _Optional[str] = ...) -> None: ... class PolicyRole(_message.Message): - __slots__ = ["allowed_agents", "allowed_labels", "allowed_services", "allowed_targets", "custom_datalog", "http", "name"] - ALLOWED_AGENTS_FIELD_NUMBER: _ClassVar[int] + __slots__ = ["allowed_labels", "allowed_services", "allowed_targets", "custom_datalog", "http", "name"] ALLOWED_LABELS_FIELD_NUMBER: _ClassVar[int] ALLOWED_SERVICES_FIELD_NUMBER: _ClassVar[int] ALLOWED_TARGETS_FIELD_NUMBER: _ClassVar[int] CUSTOM_DATALOG_FIELD_NUMBER: _ClassVar[int] HTTP_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] - allowed_agents: _containers.RepeatedScalarFieldContainer[str] allowed_labels: _containers.RepeatedScalarFieldContainer[str] allowed_services: _containers.RepeatedScalarFieldContainer[str] allowed_targets: _containers.RepeatedScalarFieldContainer[str] custom_datalog: _containers.RepeatedScalarFieldContainer[str] http: _containers.RepeatedCompositeFieldContainer[HTTPGrant] name: str - def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_agents: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ..., http: _Optional[_Iterable[_Union[HTTPGrant, _Mapping]]] = ...) -> None: ... + def __init__(self, name: _Optional[str] = ..., allowed_targets: _Optional[_Iterable[str]] = ..., allowed_services: _Optional[_Iterable[str]] = ..., custom_datalog: _Optional[_Iterable[str]] = ..., allowed_labels: _Optional[_Iterable[str]] = ..., http: _Optional[_Iterable[_Union[HTTPGrant, _Mapping]]] = ...) -> None: ... class RegisterServiceRequest(_message.Message): __slots__ = ["command", "service", "target_url"] diff --git a/sdk/python/src/agent_mesh/authorizer.py b/sdk/python/src/agent_mesh/authorizer.py index 5145cc9d..e5209349 100644 --- a/sdk/python/src/agent_mesh/authorizer.py +++ b/sdk/python/src/agent_mesh/authorizer.py @@ -52,8 +52,6 @@ class AuthorizeRequest: target_service: str # The stream protocol; names the service when target_service is "". protocol: str - # The agent the caller says it acts for; its own claim, checked against its grants. - agent: str = "" # The HTTP method and the path as the backend sees it, when the request is # HTTP. Both are injected together; a request without them (a stream that # carries no HTTP request) does not match a grant narrowed by @@ -116,14 +114,6 @@ def authorize_caller(req: AuthorizeRequest, options: ProviderAuthorizerOptions) b.add_fact(ba.Fact(BASELINE_DATALOG["fact_method"] + "({m})", {"m": req.method})) b.add_fact(ba.Fact(BASELINE_DATALOG["fact_path"] + "({p})", {"p": req.path})) - # The caller's word about which agent it acts for, limited to the agent - # namespaces its own token grants. - if req.agent: - b.add_fact(ba.Fact(BASELINE_DATALOG["fact_agent"] + "({a})", {"a": req.agent})) - for r in BASELINE_DATALOG["agent_rules"]: - b.add_rule(ba.Rule(r)) - b.add_check(ba.Check(BASELINE_DATALOG["agent_check"])) - b.add_check(ba.Check(BASELINE_DATALOG["replay_check"])) b.add_check(ba.Check(BASELINE_DATALOG["time_check"])) diff --git a/sdk/python/src/agent_mesh/conformance.py b/sdk/python/src/agent_mesh/conformance.py index 8c829578..a695a05f 100644 --- a/sdk/python/src/agent_mesh/conformance.py +++ b/sdk/python/src/agent_mesh/conformance.py @@ -70,7 +70,7 @@ def main() -> None: "reloaded_peer_id": reloaded.peer_id, "refreshed_biscuit": b64(refreshed.biscuit), "refreshed_expiration": refreshed.expiration, - "auth_frame": b64(reloaded.auth_frame("mcp://echo", "agent:example.test:conformance")), + "auth_frame": b64(reloaded.auth_frame("mcp://echo")), }, sys.stdout, ) diff --git a/sdk/python/src/agent_mesh/credential.py b/sdk/python/src/agent_mesh/credential.py index 5bcd9865..d5f839e0 100644 --- a/sdk/python/src/agent_mesh/credential.py +++ b/sdk/python/src/agent_mesh/credential.py @@ -109,11 +109,11 @@ def from_json(cls, text: str) -> "MeshCredential": ) -def encode_auth_frame(biscuit: bytes, target_service: str = "", agent: str = "") -> bytes: +def encode_auth_frame(biscuit: bytes, target_service: str = "") -> bytes: """The first frame on every mesh stream (/sam/auth/1.0.0, /sam/mcp/1.0.0): - the caller's biscuit, the service it wants and the agent it speaks for. + the caller's biscuit and the service it wants. Framing (varint length prefix) is the transport's job.""" - return pb.AuthFrame(biscuit=biscuit, target_service=target_service, agent=agent).SerializeToString() + return pb.AuthFrame(biscuit=biscuit, target_service=target_service).SerializeToString() def decode_auth_response(data: bytes) -> pb.AuthResponse: diff --git a/sdk/python/src/agent_mesh/httpx_transport.py b/sdk/python/src/agent_mesh/httpx_transport.py index 266560bc..5592d78c 100644 --- a/sdk/python/src/agent_mesh/httpx_transport.py +++ b/sdk/python/src/agent_mesh/httpx_transport.py @@ -71,9 +71,8 @@ class MeshTransport(httpx.AsyncBaseTransport): over /libp2p-http through the session. Runs under trio, as the session does.""" - def __init__(self, session: "MeshSession", *, agent: str = "", timeout: float = _REQUEST_TIMEOUT) -> None: + def __init__(self, session: "MeshSession", *, timeout: float = _REQUEST_TIMEOUT) -> None: self._session = session - self._agent = agent self._timeout = timeout async def handle_async_request(self, request: httpx.Request) -> httpx.Response: @@ -89,7 +88,6 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: target, headers=headers, body=body, - agent=self._agent, timeout=self._timeout, ) return httpx.Response(response.status, headers=list(response.headers.items()), stream=_BodyStream(response), request=request) diff --git a/sdk/python/src/agent_mesh/libp2p_http.py b/sdk/python/src/agent_mesh/libp2p_http.py index 6166673f..27764370 100644 --- a/sdk/python/src/agent_mesh/libp2p_http.py +++ b/sdk/python/src/agent_mesh/libp2p_http.py @@ -46,7 +46,6 @@ # Headers of the mesh HTTP datapath (api/network.go). HEADER_SAM_BISCUIT = "x-sam-biscuit" -HEADER_SAM_AGENT = "x-sam-agent" HEADER_PEER_ID = "x-peer-id" HEADER_SAM_NO_TRAILING_SLASH = "x-sam-no-trailing-slash" @@ -107,7 +106,7 @@ def json(self): # type: ignore[no-untyped-def] class A2AEndpoint: """This member's agent as other members reach it: `a2a://`, answered by target, the base URL of an A2A server beside this process or a handler - in it. Authorized requests are forwarded with the biscuit and agent headers + in it. Authorized requests are forwarded with the biscuit header stripped and X-Peer-Id naming the verified caller, as sam-node does. The endpoint is not announced anywhere; a caller reaches it by peer ID.""" @@ -250,7 +249,6 @@ def plain(status: int, text: str) -> tuple[int, dict[str, str], bytes]: peer_id=peer_id, target_service=target_service, protocol=str(HTTP_PROTOCOL), - agent=headers.get(HEADER_SAM_AGENT, ""), # The path as the backend sees it, decided before authorization # so path() is what policy meant, never the routing prefix. method=request.method.decode("latin-1"), @@ -268,12 +266,12 @@ def plain(status: int, text: str) -> tuple[int, dict[str, str], bytes]: if target_service != endpoint.service: return plain(404, "Service not found") - # The biscuit and the agent are for policy, not for the backend; X-Peer-Id + # The biscuit is for policy, not for the backend; X-Peer-Id # is set, not added, so an inbound value cannot pose as the verified peer. forwarded = { k: v for k, v in headers.items() - if k not in (HEADER_SAM_BISCUIT, HEADER_SAM_AGENT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length") + if k not in (HEADER_SAM_BISCUIT, HEADER_SAM_NO_TRAILING_SLASH, HEADER_PEER_ID, "host", "connection", "transfer-encoding", "content-length") } forwarded[HEADER_PEER_ID] = peer_id if upstream_path == "" and not rest: @@ -404,7 +402,6 @@ async def open_http_request( *, headers: Optional[Mapping[str, str]] = None, body: bytes = b"", - agent: str = "", timeout: float = _REQUEST_TIMEOUT, ) -> StreamedResponse: """Client side of /libp2p-http, as go-libp2p-http's RoundTripper: one @@ -415,18 +412,18 @@ async def open_http_request( sam-node's egress proxy serves one.""" service = _agent_card_service(method, target) if service is None: - return await _open_http_request(host, peer_id, biscuit, method, target, headers=headers, body=body, agent=agent, timeout=timeout) - return await _serve_agent_card(host, peer_id, biscuit, headers, agent, timeout, service) + return await _open_http_request(host, peer_id, biscuit, method, target, headers=headers, body=body, timeout=timeout) + return await _serve_agent_card(host, peer_id, biscuit, headers, timeout, service) -async def _serve_agent_card(host: IHost, peer_id: ID, biscuit: bytes, headers: Optional[Mapping[str, str]], agent: str, timeout: float, service: str) -> StreamedResponse: +async def _serve_agent_card(host: IHost, peer_id: ID, biscuit: bytes, headers: Optional[Mapping[str, str]], timeout: float, service: str) -> StreamedResponse: """Impersonates the agent's card endpoint as sam-node's egress proxy does: holds the client's request, fetches the card itself with identity encoding, and answers with it regenerated; the agent's own non-200 is relayed as it is.""" base = mesh_url(str(peer_id), service) identity = {k: v for k, v in (headers or {}).items() if k.lower() != "accept-encoding"} try: - response = await _open_http_request(host, peer_id, biscuit, "GET", mesh_http_target(service, AGENT_CARD_PATH), headers=identity, body=b"", agent=agent, timeout=timeout) + response = await _open_http_request(host, peer_id, biscuit, "GET", mesh_http_target(service, AGENT_CARD_PATH), headers=identity, body=b"", timeout=timeout) except Exception as err: # noqa: BLE001 - answered as sam-node's 502 return _bad_gateway(f"agent card fetch failed: {err}") if response.status != 200: @@ -461,14 +458,11 @@ async def _open_http_request( *, headers: Optional[Mapping[str, str]], body: bytes, - agent: str, timeout: float, ) -> StreamedResponse: out = [(k.lower(), v) for k, v in (headers or {}).items() if k.lower() not in ("host", "content-length", HEADER_SAM_BISCUIT, HEADER_PEER_ID)] out.append(("host", str(peer_id))) out.append((HEADER_SAM_BISCUIT, base64.b64encode(biscuit).decode())) - if agent: - out.append((HEADER_SAM_AGENT, agent)) out.append(("content-length", str(len(body)))) conn = h11.Connection(h11.CLIENT) @@ -505,13 +499,12 @@ async def http_request_over_stream( method: str = "GET", headers: Optional[Mapping[str, str]] = None, body: Union[bytes, str, None] = None, - agent: str = "", timeout: float = _REQUEST_TIMEOUT, ) -> HTTPResponse: """One request to /// on a peer, body read whole.""" payload = body.encode() if isinstance(body, str) else (body or b"") with trio.fail_after(timeout): - response = await open_http_request(host, peer_id, biscuit, method, mesh_http_target(target_service, path), headers=headers, body=payload, agent=agent, timeout=timeout) + response = await open_http_request(host, peer_id, biscuit, method, mesh_http_target(target_service, path), headers=headers, body=payload, timeout=timeout) try: return HTTPResponse(status=response.status, headers=response.headers, body=await response.read()) finally: diff --git a/sdk/python/src/agent_mesh/mesh.py b/sdk/python/src/agent_mesh/mesh.py index d4f45ac5..b4355068 100644 --- a/sdk/python/src/agent_mesh/mesh.py +++ b/sdk/python/src/agent_mesh/mesh.py @@ -285,10 +285,10 @@ def _sync_control_plane(self) -> ControlPlaneSync: self.save() return ControlPlaneSync(keys_changed=keys_changed, refreshed=refreshed, banned_peer_ids=banned_peer_ids, fetched_at=fetched_at, errors=errors) - def auth_frame(self, target_service: str = "", agent: str = "") -> bytes: + def auth_frame(self, target_service: str = "") -> bytes: """The frame that opens every stream to a peer: this member's biscuit plus - the service it wants (e.g. "mcp://calculator") and the agent it speaks for.""" - return encode_auth_frame(self._credential.biscuit, target_service, agent) + the service it wants (e.g. "mcp://calculator").""" + return encode_auth_frame(self._credential.biscuit, target_service) def join(self, **options): # type: ignore[no-untyped-def] """Joins the mesh: connects to the routers in the credential, passes the diff --git a/sdk/python/src/agent_mesh/session.py b/sdk/python/src/agent_mesh/session.py index 4902db4c..08ddfd83 100644 --- a/sdk/python/src/agent_mesh/session.py +++ b/sdk/python/src/agent_mesh/session.py @@ -372,7 +372,6 @@ def open_mcp( target_service: str, *, required_labels: Optional[Mapping[str, str]] = None, - agent: str = "", ): # type: ignore[no-untyped-def] """Opens an MCP session with a provider for target_service ("mcp://", or "" for the provider's own catalog tools): @@ -383,7 +382,7 @@ def open_mcp( @asynccontextmanager async def opened() -> AsyncIterator[tuple[ClientSession, VerifiedBiscuit]]: peer_id = await self.connect(peer) - frame = self.mesh.auth_frame(target_service, agent) + frame = self.mesh.auth_frame(target_service) async with open_mcp_session( self.host, peer_id, frame, self.mesh.credential.control_plane_keys, required_labels=required_labels, egress_require_labels=self.egress_require_labels ) as opened_session: @@ -536,13 +535,12 @@ async def request( method: str = "GET", headers: Optional[Mapping[str, str]] = None, body: bytes | str | None = None, - agent: str = "", ) -> HTTPResponse: """Calls an inference or A2A service on a provider over /libp2p-http, the way sam-node's egress proxy does for /sam////.""" peer_id = await self._egress_peer(peer) return await http_request_over_stream( - self.host, peer_id, self.mesh.credential.biscuit, target_service, path, method=method, headers=headers, body=body, agent=agent + self.host, peer_id, self.mesh.credential.biscuit, target_service, path, method=method, headers=headers, body=body ) async def _egress_peer(self, peer: Peer) -> ID: diff --git a/sdk/python/tests/test_authorizer.py b/sdk/python/tests/test_authorizer.py index dffc7f89..02ccaa1a 100644 --- a/sdk/python/tests/test_authorizer.py +++ b/sdk/python/tests/test_authorizer.py @@ -49,8 +49,8 @@ def options(policy_rules: list[str], own_biscuit: bytes | None = None) -> Provid return ProviderAuthorizerOptions(trusted_keys=lambda: [CP_KEY], own_biscuit=lambda: own, policy_rules=lambda: policy_rules) -def request(biscuit: bytes, target_service: str = "mcp://calc", agent: str = "") -> AuthorizeRequest: - return AuthorizeRequest(biscuit=biscuit, peer_id=CALLER, target_service=target_service, protocol="/sam/mcp/1.0.0", agent=agent) +def request(biscuit: bytes, target_service: str = "mcp://calc") -> AuthorizeRequest: + return AuthorizeRequest(biscuit=biscuit, peer_id=CALLER, target_service=target_service, protocol="/sam/mcp/1.0.0") def test_role_granted_by_mesh_policy_is_allowed(): @@ -126,16 +126,6 @@ def test_target_grants_match_the_providers_own_identity(): authorize_caller(request(node_token(CALLER)), options(rules, provider)) -def test_agent_claim_only_inside_a_granted_namespace(): - rules = [*NODE_ROLE_GRANTS, 'granted_agent_suffix(".acme.example") <- role("sam:role:node")'] - authorize_caller(request(node_token(CALLER), agent="reviewer.acme.example"), options(rules)) - with pytest.raises(AuthorizationError): - authorize_caller(request(node_token(CALLER), agent="reviewer.evil.example"), options(rules)) - with pytest.raises(AuthorizationError): - authorize_caller(request(node_token(CALLER), agent="reviewer.acme.example"), options(NODE_ROLE_GRANTS)) - authorize_caller(request(node_token(CALLER)), options(NODE_ROLE_GRANTS)) - - def test_narrowed_grant_follows_the_requests_method_and_path(): # Rendered as the control plane renders a role with # http: [{service: "mcp://calc", methods: ["GET"], paths: ["/v1/*"]}]: @@ -163,9 +153,9 @@ def http(method: str, path: str) -> AuthorizeRequest: def test_every_baseline_item_parses_in_biscuit_python(): - for c in (BASELINE_DATALOG["time_check"], BASELINE_DATALOG["replay_check"], BASELINE_DATALOG["target_check"], BASELINE_DATALOG["agent_check"]): + for c in (BASELINE_DATALOG["time_check"], BASELINE_DATALOG["replay_check"], BASELINE_DATALOG["target_check"]): ba.Check(c) - for r in BASELINE_DATALOG["rules"] + BASELINE_DATALOG["http_rules"] + BASELINE_DATALOG["agent_rules"] + BASELINE_DATALOG["target_fact_rules"]: + for r in BASELINE_DATALOG["rules"] + BASELINE_DATALOG["http_rules"] + BASELINE_DATALOG["target_fact_rules"]: ba.Rule(r) for p in BASELINE_DATALOG["policies"] + [BASELINE_DATALOG["allow_if_true"]]: ba.Policy(p) diff --git a/sdk/python/tests/test_mesh.py b/sdk/python/tests/test_mesh.py index 2c74050e..6f3c9e8d 100644 --- a/sdk/python/tests/test_mesh.py +++ b/sdk/python/tests/test_mesh.py @@ -267,10 +267,9 @@ def test_load_without_identity_says_enroll_first(tmp_path): def test_auth_frame_is_the_protobuf_with_this_members_biscuit(): cp = FakeControlPlane() mesh = AgentMesh.enroll("http://127.0.0.1:1", bootstrap_token="sbt_secret", transport=cp.transport) - frame = pb.AuthFrame.FromString(mesh.auth_frame("mcp://calculator", "agent:acme.example:bot")) + frame = pb.AuthFrame.FromString(mesh.auth_frame("mcp://calculator")) assert frame.biscuit == b"biscuit-1" assert frame.target_service == "mcp://calculator" - assert frame.agent == "agent:acme.example:bot" resp = decode_auth_response(pb.AuthResponse(success=False, error="denied").SerializeToString()) assert resp.success is False diff --git a/sts.md b/sts.md new file mode 100644 index 00000000..8f81b46f --- /dev/null +++ b/sts.md @@ -0,0 +1,692 @@ +# SAM Design Doc: Task-Based Authorization, STS & PDP Architecture + +* **Status:** Draft / Proposal (v3 — v2 incorporated the architecture and security review; v3 adds the two-token model with the control plane as OIDC issuer (section 3.5), the egress credential broker and per-destination narrowing (section 5), content inspection and non-HTTP protocols at the egress node (sections 5.6, 5.7), the security considerations (section 7) and the list of facts to verify before implementation starts (section 8)). +* **Context:** Evolving SAM from a custom sandbox network stack (`sam-box` / `nano-init`) into an **Authority, Policy Decision Point (PDP), and Task-Scoped Credential Layer** that composes with existing agent gateways (`agentgateway`, Istio, Envoy AI Gateway), sandbox runtimes (NVIDIA OpenShell, Docker Sandbox, Kubernetes `agent-sandbox`), and cloud providers (Google Cloud STS, AWS STS, Entra). +* **Primary CUJ:** an agent that runs on premises or inside a customer environment needs access to external services (BigQuery, Vertex AI and Gemini, object storage, third-party MCP servers) and to external agents, without any cloud credential existing in that environment and with the customer keeping the policy, the signing keys and the audit trail. In-mesh MCP access and ingress to the agent are secondary journeys built from the same primitives. The gateway integrations in section 4 matter where a customer already runs a mesh; on premises without one, the local `sam-node` or the SDK is the entry point and no gateway is required. + +--- + +## 1. Executive Summary & Architectural Positioning + +In traditional cloud and service-mesh architectures, permissions are granted **ambiently** to a workload identity (a Kubernetes Service Account, a GCP Service Account, a SPIFFE SVID `spiffe://...`, or Google Cloud **Agent Identity**). + +**Workload identity is necessary, but insufficient for AI agents:** +1. **Same Workload, Different Tasks (CUJs 1 & 2):** A single agent workload (e.g., a BigQuery analytics agent, a coding sandbox, or a personal assistant) executes many concurrent or sequential sessions with different least-privilege boundaries (e.g., read-only access to `dataset_A` in task 1 vs. schema mutation on `dataset_B` in task 2). Workload identity proves *which binary/container* is calling, not *what task boundary* applies to this session. +2. **Prompt Injection & Confused Deputy Risks:** If an agent session exercises the full ambient privileges of its user or workload identity, a prompt injection during a narrow task can exfiltrate or mutate unrelated resources. +3. **Multi-Hop Ephemeral Sub-Agents (CUJ 3):** When a parent agent delegates a narrower sub-task to a child agent, it needs **offline, progressive credential attenuation** (`Token2 = Attenuate(Token1, TaskRule)`)—which static IdP/OIDC tokens and 1-hop Credential Access Boundaries (CAB) cannot do without minting new identities at the IdP on every hop. + +### Core Architectural Shift + +SAM explicitly reverses the earlier preview assumption that SAM must build its own userspace TCP/IP sandbox boundary (`nano-init` + `sam-box`). **OS/container confinement belongs to the platform (`agent-sandbox`, OpenShell, `docker sbx`); gateway traffic interception belongs to the proxy (`agentgateway`, Envoy, Istio, or `sam-node`). SAM owns the cross-environment mesh, the Task-Scoped Authority (Biscuit + TAR), and the Policy Decision Point (PDP) / Credential Broker.** + +1. **Two Composable Primitives (`sam-node` + SDKs):** + * **[`sam-node`](file:///usr/local/google/home/aojea/src/sam/cmd/sam-node) is Infrastructure (PDP, STS, & Mesh Gateway):** Deployed by operators as: + 1. An **External Authorizer (`ext_authz`) & RFC 8693 STS** behind existing gateways (`agentgateway`, Istio, Envoy AI Gateway / Agent Router, kgateway). + 2. A **Mesh Provider / Egress Broker** in front of MCP servers, inference backends, and cloud APIs (`egress://`). + 3. A **Standalone Local/Cluster Gateway** (`/mcp`, `/v1/*`, `/sam/*`) when no Envoy/`agentgateway` is present (laptops, Cloud Run, simple K8s clusters). + * **SDKs ([`sdk/js`](file:///usr/local/google/home/aojea/src/sam/sdk/js), [`sdk/python`](file:///usr/local/google/home/aojea/src/sam/sdk/python)) are Native Mesh Clients:** Embedded directly in greenfield Python, Node.js, or browser agents to join the mesh and attenuate task tokens in-process. +2. **How SAM Complements `agentgateway` / Istio:** + * **`agentgateway`** (LF v1.6) validates JWTs, evaluates local CEL per MCP tool, calls Envoy `ext_authz`, and performs RFC 8693 backend token exchange (`subject_token` + `actor_token`). However, `agentgateway` is a single-cluster proxy operating on flat JWTs: it cannot do **offline multi-hop sub-agent attenuation** without an IdP round trip, nor does it provide **cross-organization P2P/relayed routing and discovery**. + * By exposing a standard **Envoy `ext_authz` gRPC/HTTP server** and a strict **RFC 8693 `/oauth/token` STS endpoint** on `sam-node`, `agentgateway` and Istio can use SAM as their **Task Authority & Cross-Cluster Mesh Backend** with zero custom code. +3. **Two-Token Model: Biscuit Inside the Mesh, JWT at Both Borders (section 3.5):** + * **Inbound border:** the caller's platform credential (OIDC JWT, Kubernetes projected token, SPIFFE JWT-SVID, Google Agent Identity token) is exchanged at the control plane for a delegated Biscuit (subject = caller, actor = node). + * **Outbound border:** the egress node verifies the Biscuit and the TAR chain, obtains a short-lived JWT for the verified principal from the control plane, which is an OIDC issuer, and exchanges that JWT at the destination's security token service (Google Cloud Workload or Workforce Identity Federation, AWS `AssumeRoleWithWebIdentity`). Biscuits never leave the mesh; external parties only ever see standard JWTs. +4. **The Egress Node Is the Sovereign Exit:** the operator chooses which nodes serve `egress://`, hence from which jurisdiction traffic leaves; no cloud credential exists in the customer environment; the cloud audit log records the SAM principal rather than a shared service account; the only key a cloud trusts is the control plane's, which the customer owns and rotates. + +--- + +## 2. Reframing Alternatives: Workload Identity vs. Task Authority + +### 2.1 SPIFFE & Workload Identity: Necessary Input, Not the Task Token + +An earlier iteration of SAM ([`api/agent.go`](file:///usr/local/google/home/aojea/src/sam/api/agent.go#L38)) used `spiffe://acme.example/prod/reviewer-7` as SAM's own agent identifier format and keyed central RBAC policies on it. That conflated two distinct layers: + +| Layer | Question Answered | Right Primitive | Role in SAM | +| :--- | :--- | :--- | :--- | +| **1. Workload / Subject Attestation** | *"Which workload or user is initiating or acting in this request?"* | **SPIFFE SVIDs** (`spiffe://...`), **Google Cloud Agent Identity** (GA SPIFFE JWTs), **K8s Projected SA JWTs**, **OIDC ID Tokens**, or **Istio XFCC**. | **Accepted as `subject_token` / `actor_token` inputs** at SAM's token exchange (or extracted from mTLS XFCC headers). | +| **2. Task / Session Authorization (TAR)** | *"What subset of standing permissions may this specific task or sub-agent hop exercise right now?"* | **SAM Task Biscuit** (Root Authority Block + Appended `TaskAuthorizationRule` Blocks). | **Issued and evaluated by SAM**, progressively attenuated offline across hops, and translated into downscoped upstream cloud credentials at egress. | + +* **Why Node Enrollment $\neq$ Caller Identity:** [`sam-node` enrollment](file:///usr/local/google/home/aojea/src/sam/internal/node/enroll.go) attests the **infrastructure node** (`peer_id`). Callers (agents, users, workloads) are **not** nodes: their workload/user credentials are exchanged into **delegated Biscuits** where the caller is the **subject** (`user(...)`) and the `sam-node` is the **actor/channel** (`actor_node(peer_id)`). + +### 2.2 Why `sam-box`, `nano-init`, and `X-Sam-Agent` Are Removed + +We are completely removing [`cmd/sam-box`](file:///usr/local/google/home/aojea/src/sam/cmd/sam-box), [`cmd/nano-init`](file:///usr/local/google/home/aojea/src/sam/cmd/nano-init), [`internal/sambox`](file:///usr/local/google/home/aojea/src/sam/internal/sambox), [`api/agent.go`](file:///usr/local/google/home/aojea/src/sam/api/agent.go), and the `X-Sam-Agent` header ([`internal/tunnel`](file:///usr/local/google/home/aojea/src/sam/internal/tunnel) has no sandbox dependency; its fate is a separate decision, section 8 item 15): + +1. **Confinement is the Sandbox Platform's Job:** `nano-init` required PID 1, `/dev/net/tun`, `CAP_NET_ADMIN`, and a userspace gVisor netstack inside the guest. Real sandbox platforms (**NVIDIA OpenShell**, **Docker Sandbox `sbx`**, **Kubernetes `agent-sandbox`**) already own the kernel/VM boundary and network namespace. +2. **`X-Sam-Agent` Was Self-Asserted by the Node:** `X-Sam-Agent` was an unverified header beside the node's own Biscuit ([`internal/node/agent.go:L38-45`](file:///usr/local/google/home/aojea/src/sam/internal/node/agent.go#L38-L45)). Replacing it with Control-Plane-signed delegated Biscuits (`POST /token/exchange`) provides cryptographic proof of the caller's subject identity. +3. **Explicit Update to `AGENTS.md`:** This design intentionally supersedes and replaces three sections in [`AGENTS.md`](file:///usr/local/google/home/aojea/src/sam/AGENTS.md): + * *Sandbox Dataplane* (`sam-box` / `nano-init` / `tun2connect`) $\rightarrow$ replaced by `sam-node` PDP/`ext_authz` + external sandbox runtimes. + * *Enforcement over Convention* $\rightarrow$ network confinement is enforced by the platform (OpenShell netns, K8s `NetworkPolicy`, Istio/Envoy, `docker sbx`); SAM enforces cryptographic task authorization at the gateway/provider PDP. + * *Agent Identity* (`Attach`/`Detach`/`Refresh`/`Status` and `AgentBundle`) $\rightarrow$ replaced by RFC 8693 Token Exchange + Biscuit Task Attenuation. + * *One schema, two encodings* $\rightarrow$ adds an explicit third wire carve-out for **RFC-mandated OAuth 2.1 / RFC 8693 endpoints** (`/oauth/token`, `/.well-known/*`), which accept RFC `application/x-www-form-urlencoded` and return RFC JSON mapped to/from messages in `api/sam.proto`. + +### 2.3 Why the Egress Node Does Not Hold a Cloud Service Account Credential by Default + +The obvious design for `egress://bigquery.googleapis.com` is a node that runs with a Google service account (Workload Identity on GKE, or a key file on premises) and calls the API as that account. It is not the default because: + +1. **It puts a cloud credential in the customer environment** when the egress node runs on premises, which is the primary CUJ. A key file or a metadata-server identity is exactly the standing credential this design removes. +2. **It loses attribution:** Cloud Audit Logs record the service account, not the agent or the user who started the task. +3. **It shares one ceiling** across every caller the node serves; narrowing per task then rests entirely on the SAM PEP. + +With the control plane as an OIDC issuer (section 3.5), the egress node obtains a per-principal JWT and exchanges it at the cloud's STS through Workload or Workforce Identity Federation. The customer's IAM grants roles to `principal://.../subject/` or `principalSet://.../attribute./`, so the cloud administrator keeps the ceiling and the audit log shows the SAM principal. Service account impersonation remains an adapter option for APIs that do not accept federated principals directly, and a node that runs inside the cloud may still use its platform identity where the operator prefers it (`platform_identity` broker, section 5.1). + +Google Credential Access Boundaries are not the task mechanism either: CAB applies to Cloud Storage only (public documentation, checked 2026-10-03), is one hop, and references predefined roles. It stays as one adapter output for `egress://storage.googleapis.com`. + +--- + +## 3. Cryptographic & Wire Design (Fixing the 3 Token Correctness Issues) + +### 3.1 Issue 3a Fix: Single Source of Truth in Appended Blocks (Zero Holder-Authored Datalog) + +A critical subtlety in `biscuit-go` v2.2.0 ([`internal/identity/biscuit.go:L40-49`](file:///usr/local/google/home/aojea/src/sam/internal/identity/biscuit.go#L40-L49)): +* `datalog.WithMaxFacts` and `datalog.WithMaxIterations` only bound **rule evaluation**, not **`check if` queries**. A holder-authored block with `0` rules but a multi-variable cross-join check (e.g. `check if label($a), label($b), label($c), label($d)`) still triggers exponential backtracking inside `check.Run()`, and `WithMaxDuration` does not preempt a running check. +* Furthermore, if a holder writes *both* Datalog `check if` statements and a serialized `TaskAuthorizationRule` protobuf in an appended block, a malicious holder can make them disagree (passing SAM's Datalog check while smuggling a broader protobuf to the Cloud STS adapter). + +**Design Invariant: SAM NEVER evaluates holder-authored Datalog (`Rules` or `Checks`).** + +1. **What an Appended Block Contains:** + When a holder (an orchestrator, `sam-node`, or an SDK session) attenuates a Biscuit with a `TaskAuthorizationRule`, the appended block $i \ge 1$ contains: + * **`0` Datalog `Rules`** + * **`0` Datalog `Checks`** + * **Exactly `1` Datalog `Fact`:** `tar_block("")` +2. **How `UnmarshalInbound` Validates & Extracts Blocks:** + In [`internal/identity/biscuit.go`](file:///usr/local/google/home/aojea/src/sam/internal/identity/biscuit.go), `UnmarshalInbound(rawToken []byte)` inspects the Biscuit protobuf envelope (`blocks[1..k]`, with `k <= MaxAttenuationBlocks = 8`) **before** building an authorizer: + * If any appended block $i \ge 1$ has `len(rules) != 0`, `len(checks) != 0`, or `len(facts) != 1` (or the single fact is not `tar_block()`), the token is **rejected immediately** (`ErrInvalidAttenuationBlock`) before `b.Authorizer()` is ever called. + * `UnmarshalInbound` decodes each `tar_block` into an `*api.TaskAuthorizationRule` and validates it with `api.ValidateTaskAuthorizationRule(tar)`. +3. **How the Verifier Enforces the `TaskAuthorizationRule` Chain:** + Because the **verifier** (not the token holder) decodes the validated `[]*api.TaskAuthorizationRule` slice from the token: + * The verifier evaluates each `TaskAuthorizationRule` directly in Go/TS/Python against the verified `RequestContext` (`service`, `method`, `path`, `host`, `port`, `mcp_tool`, `time`)—or compiles it with its own trusted code. + * **Result:** Zero attacker-authored Datalog ever runs in the Datalog VM, zero possibility of drift between SAM's PEP decision and the `TaskAuthorizationRule` forwarded to Cloud STS, and trivial $O(1)$ extraction of the `TaskAuthorizationRule` chain! + +--- + +### 3.2 Issue 3b Fix: Strictly Positive Allow-List Polarity (Intersection Semantics) + +To avoid polarity bugs when chaining multiple attenuation hops ($\text{Authority} \cap \text{TAR}_1 \cap \text{TAR}_2$), SAM's `TaskAuthorizationRule` in [`api/sam.proto`](file:///usr/local/google/home/aojea/src/sam/api/sam.proto) uses **strictly positive allow-lists**: + +```protobuf +// TaskAuthorizationRule narrows a credential's authority for a specific task or +// sub-agent hop. Across multiple appended blocks (1..k), semantics are strict +// set intersection (logical AND): a request is permitted only if it is allowed +// by the standing mesh policy AND matches at least one TaskRule in EVERY +// appended TaskAuthorizationRule block. +message TaskAuthorizationRule { + string name = 1; + string display_name = 2; + // Positive allow-list of rules for this hop. Empty rules list denies everything. + repeated TaskRule rules = 3; + // Optional shorter expiration for this task hop. + google.protobuf.Timestamp expire_time = 4; +} + +message TaskRule { + string description = 1; + + // Allowed mesh services (e.g., "mcp://bigquery", "inference://gemini-*", + // "egress://bigquery.googleapis.com"). Supports "*" and prefix/suffix + // wildcards matching SAM's service pattern grammar. Required (non-empty). + repeated string allowed_services = 2; + + // Optional operation-level allow-list. If set, the request must also match + // the specified MCP tools, HTTP methods/paths, or cloud permissions. + TaskOperation operation = 3; + + // Optional allowed upstream resource names (e.g. CRM resource prefixes + // "//bigquery.googleapis.com/projects/my-proj/datasets/sales_2026"). + repeated string allowed_resources = 4; +} + +message TaskOperation { + // Allowed MCP tool names (for mcp:// services) or cloud IAM permissions + // (e.g. "bigquery.googleapis.com/datasets.get", "bigquery.googleapis.com/tables.*"). + repeated string allowed_actions = 1; + // Allowed HTTP methods (e.g. ["GET", "POST"]). + repeated string allowed_methods = 2; + // Allowed HTTP path patterns (e.g. ["/bigquery/v2/projects/my-proj/*"]). + repeated string allowed_paths = 3; +} +``` + +* **How Multi-Hop Narrowing Works (CUJ 3):** + * **Hop 1 (Orchestrator $\rightarrow$ Analytics Agent):** Appends `TAR_1` with `allowed_resources: ["//bigquery.googleapis.com/projects/my-proj/datasets/sales_2026/*"]` and `allowed_actions: ["bigquery.googleapis.com/datasets.get", "bigquery.googleapis.com/tables.get", "bigquery.googleapis.com/tables.getData"]`. + * **Hop 2 (Analytics Agent $\rightarrow$ Sub-Agent):** Appends `TAR_2` with `allowed_resources: ["//bigquery.googleapis.com/projects/my-proj/datasets/sales_2026/tables/q1"]` and `allowed_actions: ["bigquery.googleapis.com/tables.getData"]`. + * Both SAM PEPs and the Cloud STS adapter compute the **intersection** ($\text{TAR}_1 \cap \text{TAR}_2$), which cleanly narrows access to `tables.getData` on `tables/q1` only. +* **Where Inversion Happens:** If a downstream cloud API (such as Google's internal TAR spec) uses a `DENY`-with-`excludedPermissions` wire format, the **Google Cloud STS Adapter** in `EgressService` computes the intersection of the allow-lists across blocks $1 \dots k$ and writes that intersected allow-list into Google's `excludedPermissions` / `excludedResources` fields. + +--- + +### 3.3 Issue 3c Fix: Subject vs. Actor (`actor_node`) & Stateless Control-Plane Exchange + +When a `sam-node` exchanges a workload's or user's credential on their behalf, the resulting Biscuit must distinguish **who owns the transport channel (the actor node)** from **whose identity is being exercised (the subject)**, matching RFC 8693 (`sub` vs. `act`): + +1. **Direct Member Biscuit (minted at `/enroll` or `/register` for a node or native SDK peer):** + * Authority facts: `node("")`, `client_peer_id("")`, `user("")`, `role("")`, `expiration(