User guide

Concepts

Role Who Trusted for
Owner The enterprise whose data, credentials and policy are at stake Everything. Holds the owner key (P-256) on its own machine
Agent The model and code in the sandbox — the owner's own, open source, or a vendor's Nothing. Its behavior depends on what it reads, so TAP never relies on it
Platform The sandbox provider (e.g. Cloudflare) Running the sandbox. Not for enforcing the policy, keeping credentials or keeping the audit
TAP The proctor, in a confidential VM Exactly what its attested code does — which the owner checks

TAP does not try to understand the agent. It decides on what leaves the sandbox: each HTTP(S) request, by host, method, path and size.

How a request flows

  1. The platform opens a session with TAP for each sandbox, authenticated with the platform token whose SHA-256 the owner put in the policy.
  2. Each request the sandbox sends goes to the platform's adapter, which asks TAP (decide):
    • decide mode hosts: TAP allows or denies; the adapter sends allowed requests itself and reports the response's status and hash (complete).
    • relay mode hosts: TAP answers relay; the adapter hands TAP the whole request (relay); TAP adds the credential, sends it, records it and returns the response.
    • anything else: denied, with the reason; the agent sees an HTTP 403 Blocked by TAP: <reason>.
  3. When the sandbox ends, the platform ends the session; TAP signs a claim over the session's records.
  4. The owner fetches the records and the claim, and checks them against TAP's attested key.

If the adapter cannot reach TAP, it sends nothing (HTTP 503).

Trust model

What TAP guarantees

Deployment shapes

Separate (today) Co-located (later)
Where Sandbox at the platform; TAP in its own confidential VM Sandbox and TAP in one confidential boundary
Policy, credentials, audit protected yes yes
All traffic goes through TAP relies on the platform routing it (credentialed requests cannot bypass TAP: the credential is only there) yes
The agent's data hidden from the platform no — the sandbox is not a TEE yes

Current limitations (developer preview)

Policy reference

A policy is JSON, signed by the owner over SHA-256("tap-policy/v1" | policy text).

{
  "v": 1,
  "version": 2,
  "caller_token_sha256": "ff63db0a…",
  "egress": [
    { "host": "api.github.com", "methods": ["GET"], "paths": ["/repos/acme/demo/"], "mode": "relay",
      "credential": "github", "inject": { "header": "Authorization", "format": "Bearer {}" } },
    { "host": "api.github.com", "methods": ["POST"], "paths": ["/repos/acme/demo/issues/"], "mode": "relay",
      "credential": "github", "inject": { "header": "Authorization", "format": "Bearer {}" }, "max_body": 16384 },
    { "host": "pypi.org", "methods": ["GET"], "mode": "decide" }
  ]
}
Field Meaning
v Always 1
version Positive integer; each new policy must be higher (the CLI and console bump it)
caller_token_sha256 SHA-256 (hex) of the platform token: who may open sessions
data_class Optional label ([a-z0-9-], e.g. confidential): the highest data class you declare for these sessions, carried into TRACE records
egress The rules. Anything no rule allows is denied

A rule:

Field Required Meaning
host yes Lowercase host name, or *.example.com for its subdomains (not example.com itself)
methods yes Some of GET HEAD POST PUT PATCH DELETE
paths no Path prefixes starting with /. /repos/acme/demo/ matches /repos/acme/demo and everything under it, not /repos/acme/demo2. All paths if absent
mode yes decide (the platform sends it) or relay (TAP sends it)
credential no Name of a sealed credential; needs relay and inject
inject with credential {"header": NAME, "format": TEXT}; {} in format is replaced by the secret
max_body no Largest request body in bytes (default 1 MiB)

Unknown fields make a policy invalid, so a typo cannot loosen it.

Request matching. Only https:// on the default port, with no user part. Paths are normalized first (/a/b/../c is /a/c); encoded slashes (%2f, %5c) are refused. The query string does not take part. The first rule matching host, method and path applies.

Credentials

  1. Attest TAP (the statement names its X25519 key).
  2. Seal: ephemeral X25519 with TAP's key → HKDF-SHA256 (salt: both public keys; info tap-credential/v1) → AES-256-GCM, with the credential's name as associated data.
  3. Sign the name and the sealed envelope with the owner key and upload.

The console and sdk/cli.ts credential do all three. A credential sealed to another TAP, for another name, or not signed by the owner is refused.

Attestation

POST /v1/attest {"nonce"} returns:

The client checks: the nonce is its own; sig verifies with sign_key; the report's report data is the statement's SHA-512; the report is signed by the chip's VCEK, which AMD's ASK and ARK certify (fetched from AMD's Key Distribution Service, ARK pinned); the guest policy does not allow debugging; optionally, the launch measurement is one you accept (--measurement).

TAP's keys are new at every start, so attest again after a restart.

Audit records and claims

A record (one per request):

{"seq":5,"method":"GET","host":"paste.example-logs.dev","path":"/ci-run-4417","mode":"decide",
 "decision":"deny","reason":"the policy does not allow this host","req_sha256":"e3b0…","status":null,"resp_sha256":null}

Bodies are never recorded, only their SHA-256.

The chain: h0 = SHA-256("tap/1" | session id), hn = SHA-256(hn-1 | record n) with records as compact JSON in the field order above.

The claim, signed by TAP at the end:

{"v":1,"kind":"tap-session","session","sandbox","platform":"amd-sev-snp","owner","policy","version",
 "started","ended","calls","chain"}

sdk/cli.ts audit and the console recompute the chain from the records and check the claim against the attested sign_key, the owner and the policy.

CLI

node sdk/cli.ts keygen OWNER.pem
node sdk/cli.ts token
node sdk/cli.ts provision  --url URL --owner-key PEM [--policy FILE] [--credentials ENVFILE]
node sdk/cli.ts credential --url URL --owner-key PEM --name NAME --from-env VAR
node sdk/cli.ts audit      --url URL --owner-key PEM [--session ID]
node sdk/cli.ts trace      --url URL --owner-key PEM --session ID [--out FILE]
  common: --measurement HEX (repeatable)   --allow-software (development only)

trace exports an ended session as a TRACE Trust Record and checks it. provision attests, registers your key if TAP has no owner (and stops if TAP belongs to another key), and installs the policy at a version above TAP's. credential reads the secret from an environment variable, so it never appears on the command line. provision --credentials seals every TAP_CREDENTIAL_<NAME>=<secret> in a local env file (name lowercased, _ to -), for a TAP that restarted.

HTTP API

Who Call Body → reply
anyone GET /v1/health → {ok, platform, booted}
anyone POST /v1/attest {nonce} → {statement, sig, evidence}
owner POST /v1/owner {key} (P-256, 04‖X‖Y, base64) → {ok}; 409 if there is one
owner POST /v1/policy {policy, sig} → {version}
owner POST /v1/credentials {name, sealed, sig} → {ok}
owner GET /v1/sessions header x-tap-owner: <ms>.<sig> → {sessions}
owner GET /v1/sessions/ID same header → {records, claim}
owner GET /v1/sessions/ID/trace[?nonce=N] same header (signed over the path without the query) → a TRACE Trust Record (profile)
platform POST /v1/sessions {sandbox, model?} → {session}; model = {provider, model_id, version?}, the agent's model as the platform states it
platform POST /v1/sessions/ID/decide {method, url, body_sha256, body_size} → {allow, seq} or {allow:false, reason, seq} or {allow:false, relay:true}
platform POST /v1/sessions/ID/complete {seq, status, resp_sha256} → {ok}
platform POST /v1/sessions/ID/relay {method, url, headers, body} → {allow:true, status, headers, body} or {allow:false, reason}
platform POST /v1/sessions/ID/end → {claim, sig}

Platform calls carry Authorization: Bearer <platform token>. Owner reads are signed over GET\n<path>\n<ms> with label tap-read/v1, valid for five minutes.

The Cloudflare adapter

cloudflare/ is a Worker. Its AgentSandbox Durable Object runs one task per sandbox:

  1. opens a TAP session;
  2. routes the container's outbound traffic to the TapGateway entrypoint — interceptOutboundHttps("*") and interceptAllOutboundHttp — before the container starts, and starts it with enableInternet: false;
  3. trusts the sandbox's interception CA, writes the demo agent into the container and runs it;
  4. streams the agent's events back, ends the session and returns TAP's claim.

TapGateway is src/gateway.ts: decide, relay, refuse or fail closed, as in How a request flows. It has no Cloudflare-specific code, so it can be reused by other adapters.

The demo agent (cloudflare/sandbox/agent.mjs) is a model with one bash tool, using an OpenAI-compatible API. It sends the placeholder Bearer via-tap as its model key; TAP replaces it.

Deploying TAP

TAP needs Node.js 22.18+ and, for hardware evidence, Linux 6.7+ with configfs-tsm (/sys/kernel/config/tsm/report) in an AMD SEV-SNP guest.

node src/server.ts --port 443 --backend tsm --tls-cert FILE --tls-key FILE
node src/server.ts --port 8443 --backend software          # development only: no hardware evidence

deploy/gcp/ creates a GCP n2d SEV-SNP VM and installs TAP behind a Let's Encrypt certificate. TAP terminates TLS itself, inside the confidential VM.

FAQ

Does TAP speak a standard audit format? Each ended session can be exported as a TRACE v0.2 Trust Record, the format cMCP and other agent-governance tools use.

Does the agent need changes? No. It makes ordinary HTTP requests; the platform routes them through TAP.

Does TAP read request bodies? In relay mode it forwards them; in either mode it records only their hashes.

What if the platform skips TAP? In the separate shape, requests the platform sends without asking TAP are not in the audit — but they cannot carry your credentials, which only TAP holds. The co-located shape removes this gap.

Why open source? TAP asks you not to trust its operator. You should be able to read the code and, with measured images, check that the attested build is this code.