# SproutPad sandbox reference proof

The canonical availability document is
[`/.well-known/sandbox-reference.json`](/.well-known/sandbox-reference.json).
It says `state: "available"` only after the isolated production sandbox has
minted a proof and the repository contains the exact public artifact, the two
public JWKS snapshots, and its capture provenance.

When available, verify the known-good artifact without a browser, account,
database, or package install. The manifest and every file it hashes are served
from one immutable Worker asset release on both public hosts. The raw hash
checks prove that the live release is internally coherent before any
downloaded JavaScript is executed:

```sh
(
set -eu
reference_dir="$(mktemp -d)"
chmod 700 "$reference_dir"
trap 'rm -rf "$reference_dir"' EXIT
curl --proto '=https' --tlsv1.2 -fsS \
  -o "$reference_dir/manifest.json" \
  https://sproutpad.ai/.well-known/sandbox-reference.json
test "$(jq -er '.state' "$reference_dir/manifest.json")" = available
proof_id="$(jq -er '.proofId' "$reference_dir/manifest.json")"
printf '%s' "$proof_id" | grep -Eq '^sbp_[A-Za-z0-9_-]{20,32}$'

curl --proto '=https' --tlsv1.2 -fsS -o "$reference_dir/proof.json" \
  https://sproutpad.ai/reference/sandbox/proof.json
curl --proto '=https' --tlsv1.2 -fsS -o "$reference_dir/proof-jwks.json" \
  https://sproutpad.ai/reference/sandbox/proof-jwks.json
curl --proto '=https' --tlsv1.2 -fsS -o "$reference_dir/issuer-jwks.json" \
  https://sproutpad.ai/reference/sandbox/issuer-jwks.json
curl --proto '=https' --tlsv1.2 -fsS -o "$reference_dir/provenance.json" \
  https://sproutpad.ai/reference/sandbox/provenance.json
curl --proto '=https' --tlsv1.2 -fsS -o "$reference_dir/verifier.mjs" \
  https://sproutpad.ai/verify-sandbox-proof.mjs

check_sha256() {
  expected="$(jq -er --arg field "$1" '.[$field]' "$reference_dir/manifest.json")"
  printf '%s' "$expected" | grep -Eq '^[a-f0-9]{64}$'
  actual="$(shasum -a 256 "$2" | awk '{print $1}')"
  test "$actual" = "$expected"
}
check_sha256 artifactSha256 "$reference_dir/proof.json"
check_sha256 proofJwksSha256 "$reference_dir/proof-jwks.json"
check_sha256 issuerJwksSha256 "$reference_dir/issuer-jwks.json"
check_sha256 provenanceSha256 "$reference_dir/provenance.json"
check_sha256 verifierSha256 "$reference_dir/verifier.mjs"

curl --proto '=https' --tlsv1.2 -fsS \
  -o "$reference_dir/api-proof.json" \
  "https://sproutpad.ai/v1/sandbox/proofs/$proof_id"
jq -e --slurp '.[0] == .[1].data' \
  "$reference_dir/proof.json" "$reference_dir/api-proof.json" >/dev/null

node "$reference_dir/verifier.mjs" \
  "$reference_dir/proof.json" \
  "$reference_dir/proof-jwks.json" \
  "$reference_dir/issuer-jwks.json" \
  --tamper-check
)
```

`--tamper-check` changes the downloaded artifact only in memory and must report
that the altered proof was rejected. A failed hash or API-artifact comparison
is a hard stop, even if the signatures would otherwise be self-consistent.

## Run the same holder protocol without a browser

Download the standalone Node 22 holder program, bind it to the API-served
manifest before execution, and create a P-256 key outside the repository. The
program writes the key with owner-only permissions and refuses a path inside
any Git worktree.

```sh
(
set -eu
holder_manifest="$(mktemp)"
holder_program="$(mktemp)"
chmod 600 "$holder_manifest"
chmod 600 "$holder_program"
trap 'rm -f "$holder_manifest" "$holder_program"' EXIT
curl --proto '=https' --tlsv1.2 -fsS \
  -o "$holder_manifest" \
  https://sproutpad.ai/.well-known/sandbox-reference.json
curl --proto '=https' --tlsv1.2 -fsS \
  -o "$holder_program" \
  https://sproutpad.ai/sandbox-holder.mjs
expected_holder_sha="$(jq -er \
  'if .state == "available" then .holderCliSha256 else .verification.holderCliSha256 end' \
  "$holder_manifest")"
printf '%s' "$expected_holder_sha" | grep -Eq '^[a-f0-9]{64}$'
test "$(shasum -a 256 "$holder_program" | awk '{print $1}')" = \
  "$expected_holder_sha"
mv "$holder_program" ./sandbox-holder.mjs
node ./sandbox-holder.mjs keygen \
  --key-file "$HOME/.config/sproutpad/sandbox-holder.private.jwk.json"
)
```

The agent (or a separate terminal) creates a session. Keep the response in a
mode-0600 temporary file because it contains the sandbox-only agent capability.

```sh
session_file="$(mktemp)"
chmod 600 "$session_file"
trap 'rm -f "$session_file"' EXIT
curl -fsS -X POST https://sproutpad.ai/v1/sandbox/sessions >"$session_file"
session_id="$(jq -er '.data.session.id' "$session_file")"
jq -e '
  (.data.actions | type == "array") and
  (.data.actions | length >= 1) and
  (.data.actions | all(.kind == "request" or .kind == "stop")) and
  (has("nextActions") | not)
' "$session_file" >/dev/null
```

Creation and authenticated reads use the same resource location:
`data.session`. The creation response alone also contains
`data.authorizationHeader` and `data.agentCapability`; neither secret is
returned by `GET /v1/sandbox/sessions/:id`. This is the deliberate
2026-07-16 public-beta migration from the former flat read shape
(`data.id`, `data.budget`, and peer fields). There is no duplicate flat alias.

The holder process receives the session id but never the agent capability. It
fetches the published sandbox issuer key, signs the same $100 fake-money open
mandate as the browser, and registers it directly.

```sh
node ./sandbox-holder.mjs mandate \
  --base-url https://sproutpad.ai \
  --session-id "$session_id" \
  --key-file "$HOME/.config/sproutpad/sandbox-holder.private.jwk.json"
```

Only after the holder process exits, load the capability into the agent shell
and run a fixed scenario.

```sh
export SPROUTPAD_SANDBOX_AUTHORIZATION="$(jq -er '.data.authorizationHeader' "$session_file")"
curl -fsS -X POST \
  -H "Authorization: $SPROUTPAD_SANDBOX_AUTHORIZATION" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: headless-$(date +%s)-$$" \
  --data '{"scenario":"allowed"}' \
  "https://sproutpad.ai/v1/sandbox/sessions/$session_id/attempts"
unset SPROUTPAD_SANDBOX_AUTHORIZATION
rm -f "$session_file"
trap - EXIT
```

For the `approval` scenario, save only the returned `gate` object, unset the
agent capability, and invoke `sandbox-holder.mjs decision --gate-file …`. The
holder CLI signs the exact request digest, nonce, outcome, and expiry and posts
that decision without agent authority.

This headless path proves control of the holder key; it does **not** by itself
prove that a human looked at a screen or made the decision. Automated or CI
use must be described as a simulated holder and cannot satisfy SproutPad's
human-separation conformance claim.

## Provenance boundary

Sandbox proof v1 signs the proof artifact and its AP2 evidence. It does not
cryptographically bind an application build or this repository's provenance
file. The published provenance therefore labels the runtime build as an
unsigned observation, not as a signed build attestation. A future proof schema
would be required to add that binding.
