Skip to content

Fix guest agent tenant-token exposure: worker-scoped credentials (GH-64)#96

Merged
haasonsaas merged 5 commits into
mainfrom
fix/worker-scoped-guest-tokens
Jul 9, 2026
Merged

Fix guest agent tenant-token exposure: worker-scoped credentials (GH-64)#96
haasonsaas merged 5 commits into
mainfrom
fix/worker-scoped-guest-tokens

Conversation

@haasonsaas

@haasonsaas haasonsaas commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Threat model

The guest agent running inside a sandbox (untrusted code) authenticated to the control plane with the same tenant-wide bearer token the CLI and admin tooling use for everything. The API only ever mapped a token to a tenant (auth_and_tenantTenantContext { tenant_id }), never to a specific worker or sandbox. Any code executing inside a sandbox could read that token out of its own environment and then act as the entire tenant: claim other workers' leases (exfiltrating other jobs' argv/env), forge lease completions/failures, post guest-health for sandboxes it has nothing to do with, etc. Sandbox compromise was equivalent to tenant-wide control-plane compromise, which defeats the point of a sandbox.

Design

1. Mint a worker-scoped credential at registration. POST /workers/register now generates a high-entropy token (sbw_wtok_<64 hex chars>), stores only its SHA-256 hash in a new nullable, uniquely-indexed workers.token_hash column (migration 20260709000100_worker_tokens.sql, following the alter table pattern in tenant_boundaries.sql), and returns the raw token once in the response (WorkerResponse.worker_token). It is never returned again.

2. The API resolves that token to (tenant_id, worker_id), not just a tenant. auth_and_tenant routes any bearer token with the sbw_wtok_ prefix to a hash lookup (resolve_worker_token) instead of the static tenant/shared-token lists, producing a TenantContext that now carries an optional worker_id. A prefixed token that fails to resolve is unconditionally 401 — it never falls through to tenant-token checks (which would let a rejected worker-token attempt be retried as a tenant-wide one).

3. Guest-facing routes reject tenant-wide tokens and enforce ownership. ensure_worker_scope / ensure_lease_worker_scope / ensure_sandbox_worker_scope require ctx.worker_id to be Some and to match the resource being acted on:

  • claim_lease — token's worker id must equal the {worker_id} path param.
  • renew_lease / complete_lease / fail_lease / append_lease_output — token's worker id must equal lease.worker_id.
  • update_guest_health (POST only) — token's worker id must have completed a provision_sandbox/fork_sandbox lease for that sandbox. Sandboxes carry no persistent "owning worker" column, so this is derived from completed-lease history (worker_owns_sandbox) rather than a new denormalized field — avoiding a sandboxes schema change and matching what's actually the source of truth (whichever worker's guest environment the sandbox lives in).

Cross-worker attempts return 404 (mirroring the existing ensure_tenant convention of not confirming/denying another principal's resource exists); a missing worker scope (i.e. a tenant-wide token) returns 401. GET /sandboxes/{id}/guest-health and every CLI/admin route (register, heartbeat, reconcile-runtime-resources, job creation, snapshot cleanup, etc.) are unchanged and keep accepting tenant-wide tokens.

4. The worker process propagates the new token. sandboxwich-worker run (register + work loop in one process — what the checked-in Deployment actually runs) captures worker_token from the registration response and builds a second API client from it, using that for everything afterward (heartbeat, claim, renew, complete, fail) instead of the tenant token passed in via SANDBOXWICH_API_TOKEN. The tenant token is now used for exactly one call: the initial POST /workers/register.

Compatibility

  • Existing/already-running workers: rows inserted before this migration have token_hash = NULL and will get 401 on every guest-facing route until they next re-register (which mints a token). Since workers are ephemeral processes that re-register on every restart/redeploy, this self-heals on the next rolling deploy; it's called out here in case anyone runs a long-lived worker process across this deploy without a restart.
  • work-loop / claim / renew / complete / fail / heartbeat invoked directly (not via run): unchanged code, but an operator must now pass the worker-scoped token from a prior register call via --api-token/SANDBOXWICH_API_TOKEN for those calls to succeed against guest-facing routes. Documented on Cli::api_token and in worker.yaml.
  • CLI/admin tooling: no changes. Tenant tokens keep working exactly as before everywhere except the guest-facing routes above.

Deviations from the task's spec

  • No sandboxes schema change. Rather than adding an assigned_worker_id column (and updating the raw-SQL insert_sandbox_sql test helper and assert_database_rejects_invalid_typed_values), sandbox ownership for guest-health is derived from completed provision_sandbox/fork_sandbox lease history (worker_owns_sandbox), which is already the actual source of truth and requires no migration.
  • constant_time_eq not reused for worker-token verification. Tenant/operator tokens are a small, static, in-memory list compared via constant_time_eq. Worker tokens are dynamic, per-worker DB rows; the lookup key is the SHA-256 hash itself, via an indexed WHERE token_hash = ?. The security property here comes from hash preimage resistance, not timing-safe comparison — this is the standard pattern for hashed API keys at DB scale, not a shortcut.
  • No guest-pod env injection wiring. I verified neither KubernetesDryRunProvider nor KubernetesApplyProvider currently injects SANDBOXWICH_API_TOKEN/SANDBOXWICH_WORKER_ID into a guest pod's env at all today — only SANDBOXWICH_WORKSPACE/SSH_PORT/AUTHORIZED_KEYS_FILE/VNC_PASSWORD are wired, and none of those follow the plain-env-var pattern for secrets (VNC password is secretKeyRef-only). Building that out (mirroring ssh_authorized_keys_secret/vnc_password_secret) is a distinct, sizable feature — it needs the worker to create/manage a Secret per worker, thread a new field through SandboxProvisionSpec and both providers' manifest rendering, and update their existing manifest-assertion tests. Left as a follow-up. This PR's job is to guarantee that whenever/however that wiring lands, the only credential it can carry into a guest is a worker-scoped token, and the API enforces its scope regardless of how it got there.
  • The audit's "file/command routes the agent calls" turned out to be empty. I traced sandboxwich-agent's actual HTTP calls: it only ever hits /workers/{id}/leases/claim, /leases/{id}/{fail,complete,output}, and /sandboxes/{id}/guest-health. File read/write are local CLI subcommands (no HTTP), and command execution goes through the worker's exec_handoff (the worker execs into the sandbox, not the sandbox polling the API). No file/command HTTP routes needed scoping beyond what's above.

Test plan

  • worker_scoped_tokens_enforce_guest_route_boundaries (new): worker B's token cannot claim/renew/append-output/complete/fail worker A's lease or post guest-health for worker A's sandbox (404s); a tenant-wide token is rejected outright on every one of those routes (401s), including against its own tenant's resources; the rightful worker completing its own lease and reporting its own sandbox's health still works end to end.
  • Updated every existing test that exercises lease claim/renew/complete/fail/output/guest-health (full sqlite+postgres lifecycle contract, provision/rollback/retry/expiry/prompt/snapshot-fork helpers) to authenticate with a worker-scoped client instead of the tenant client.
  • cargo fmt --all -- --check clean.
  • cargo clippy --workspace --all-targets -- -D warnings clean.
  • cargo test --workspace: 78 tests passing (14 agent, 6 api-unit, 10 http-contract including the new test, 3 bench, 4 cli, 2 core, 39 worker). Postgres-gated lifecycle test skips (no SANDBOXWICH_TEST_POSTGRES_URL in this environment) per repo convention.

Fixes #64

🤖 Generated with Claude Code


Open in Devin Review

…H-64)

Threat model: the guest agent inside a sandbox authenticates to the
control plane, but until now it only ever carried the same tenant-wide
bearer token the CLI uses, so a compromised sandbox could act as the
whole tenant.

This lays the data-model groundwork for a worker-scoped credential:
- migrations/20260709000100_worker_tokens.sql adds a nullable, uniquely
  indexed workers.token_hash column (SHA-256 of the worker's minted
  token, never the raw value), following the tenant_boundaries.sql
  migration's alter-table pattern.
- WorkerResponse gains an optional worker_token field, populated only
  on POST /workers/register's response and never returned again.

No behavior changes yet: nothing mints, hashes, or verifies tokens
against this column until the next commit.
Fixes #64: the guest agent running inside an untrusted sandbox
authenticated with the same tenant-wide bearer token used by the CLI,
so any compromised sandbox could act as the whole tenant (claim/forge
any lease, post guest-health for any sandbox, etc).

- register_worker now mints a high-entropy token
  (generate_worker_token, prefixed "sbw_wtok_" so the auth middleware
  can route it without an extra DB round trip for every other
  request), stores only its SHA-256 hash (hash_worker_token), and
  returns the raw token once in the response.
- auth_and_tenant resolves a "sbw_wtok_"-prefixed bearer token by
  hashing it and looking it up against workers.token_hash
  (resolve_worker_token), producing a TenantContext carrying both
  tenant_id and worker_id. A token with this prefix that fails to
  resolve is always unauthorized -- it never falls through to the
  tenant-token checks, which would let a rejected worker token be
  retried as a tenant-wide one. Unlike the small in-memory tenant/
  operator token lists (compared with constant_time_eq), the lookup
  key here is a cryptographic hash, so the security property comes
  from SHA-256 preimage resistance rather than timing-safe comparison
  -- a plain indexed equality lookup is the correct, standard pattern
  for hashed credentials at DB scale.
- ensure_worker_scope/ensure_lease_worker_scope/ensure_sandbox_worker_scope
  enforce that a worker-scoped token may only act on its own worker id,
  its own leases (by lease.worker_id), and sandboxes it has actually
  provisioned or forked (worker_owns_sandbox, derived from completed
  provision/fork lease history -- sandboxes carry no persistent
  "owning worker" column, and completed-lease history is the only
  source of truth for which worker's guest environment a sandbox
  actually runs in). A tenant-wide token is rejected outright on these
  checks with 401, not merely denied access to another worker's
  resources.
- Applied to every guest-facing route: claim_lease, renew_lease,
  append_lease_output, complete_lease, fail_lease, and the POST side
  of guest-health. The GET side of guest-health, CLI/admin routes
  (register, heartbeat, reconcile-runtime-resources, job creation,
  etc.) are unchanged and keep accepting tenant-wide tokens.

Adds sha2 as a direct dependency (already vendored transitively) and
enables uuid's v4 feature for token generation.
GH-64)

The `run` subcommand (register + work loop in one process, what the
checked-in worker Deployment actually runs) now captures the
worker_token returned by registration and builds a second API client
from it, using that -- not the tenant token passed in via
SANDBOXWICH_API_TOKEN/--api-token -- for the rest of the process's
lifetime: heartbeat, lease claim/renew/complete/fail. The tenant token
is used for exactly one call, the initial POST /workers/register,
since a worker has no worker id (and therefore no worker-scoped token)
before that responds.

`work-loop`/`claim`/`renew`/`complete`/`fail`/`heartbeat` invoked
directly (not via `run`) are unchanged: they already take whatever
token the operator passes via --api-token/SANDBOXWICH_API_TOKEN, so an
operator now needs to pass the worker-scoped token returned by a prior
`register` call for those to succeed against the guest-facing routes.
Documented on the Cli::api_token field and in worker.yaml.

Deviation from the task's "inject it into sandbox env" ask: no
provider (KubernetesDryRunProvider/KubernetesApplyProvider) currently
injects SANDBOXWICH_API_TOKEN/WORKER_ID into a guest pod's env at all
-- only SANDBOXWICH_WORKSPACE/SSH_PORT/AUTHORIZED_KEYS_FILE/
VNC_PASSWORD are wired today, and the guest agent's Daemon subcommand
that reads those env vars is a code path this provider doesn't drive.
Wiring real secret-backed propagation into pod manifests (mirroring
the ssh_authorized_keys_secret/vnc_password_secret pattern) is a
distinct, sizable feature and left as a follow-up; this commit ensures
that whenever/however that wiring lands, the only credential it can
carry into a guest is this worker-scoped token, and the API enforces
its scope regardless of injection mechanism.
Adds worker_scoped_tokens_enforce_guest_route_boundaries: registers
two workers, gives each a sandbox it actually provisioned, and asserts
worker B's token cannot claim on worker A's behalf, or
renew/append-output/complete/fail worker A's lease, or post
guest-health for sandbox A (all 404, matching ensure_tenant's existing
convention of not confirming/denying another principal's resource
exists); that a tenant-wide token is rejected outright (401) on every
one of those same routes, including acting on its own tenant's
worker/lease/sandbox -- not just cross-worker access; and that the
happy path (the rightful worker completing its own lease and reporting
its own sandbox's guest health) still works end to end.

Updates every existing test that exercises lease
claim/renew/complete/fail/output or guest-health (the full sqlite/
postgres lifecycle contract, provision/rollback/retry/expiry/prompt/
snapshot-fork helpers) to authenticate those specific calls with a
worker_client(&worker) built from the new worker_token instead of the
tenant client, since those routes now reject tenant-wide tokens.
assert_guest_health_and_ssh_key_lifecycle's call in run_contract also
moves to after a real provision lease completes, since guest-health
now requires the worker-scoped token whose lease-history proves it
owns the sandbox.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment on lines +1688 to +1714
let sql = format!(
"select j.kind, j.payload
from job_leases jl
join jobs j on j.id = jl.job_id
where jl.worker_id = {} and jl.status = 'completed'
and j.kind in ('provision_sandbox', 'fork_sandbox')",
db.placeholder(1)
);
let rows = sqlx::query(&sql)
.bind(worker_id.to_string())
.fetch_all(&db.pool)
.await?;
let sandbox_id_str = sandbox_id.to_string();
for row in rows {
let kind: String = row.try_get("kind")?;
let payload_raw: String = row.try_get("payload")?;
let payload: serde_json::Value = serde_json::from_str(&payload_raw)?;
let field = if kind == "fork_sandbox" {
"childSandboxId"
} else {
"sandboxId"
};
if payload.get(field).and_then(serde_json::Value::as_str) == Some(sandbox_id_str.as_str()) {
return Ok(true);
}
}
Ok(false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Sandbox ownership query fetches all completed leases instead of filtering by sandbox in SQL

The worker_owns_sandbox function at crates/sandboxwich-api/src/main.rs:1683-1715 fetches ALL completed provision/fork job leases for a given worker, then iterates through each row in Rust to parse the JSON payload and check if the sandbox ID matches. For a long-running worker that has provisioned many sandboxes, this could return a large number of rows. The sandbox ID filter could be pushed into the SQL query itself (e.g., using JSON extraction functions or a LIKE clause on the payload column) to reduce the result set. This isn't a correctness issue but could become a performance bottleneck in production.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}))
}

async fn heartbeat_worker(

@devin-ai-integration devin-ai-integration Bot Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Heartbeat route intentionally does not enforce worker-scoped auth

The heartbeat_worker API handler at crates/sandboxwich-api/src/main.rs:2837-2898 uses ensure_worker_tenant (tenant-level check) but does NOT call ensure_worker_scope (worker-scoped token check). Meanwhile, the worker-side Run command at crates/sandboxwich-worker/src/main.rs:591-594 switches to the worker-scoped client for heartbeats. This works because the worker-scoped token resolves to a valid TenantContext with a tenant_id, and ensure_worker_tenant only checks tenant_id. The asymmetry is intentional — heartbeat is not listed as a "guest-facing route" in the PR's design — but it means a tenant-wide token can still heartbeat on behalf of any worker in that tenant, which may or may not be the desired security posture.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +26 to +33
/// For `register`/`run`: a tenant-wide token, used only to authenticate
/// the initial `POST /workers/register` call. `run` then mints and
/// switches to a worker-scoped token (GH-64) for everything after
/// registration, so this value is never reused for lease/guest-health
/// calls in that path. For every other subcommand (`work-loop`, `claim`,
/// `renew`, `complete`, `fail`, `heartbeat`), pass the worker-scoped
/// token returned by `register` here instead -- those routes reject
/// tenant-wide tokens.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Standalone CLI subcommands require manual worker token management

The Run command at crates/sandboxwich-worker/src/main.rs:550-605 automatically mints and switches to the worker-scoped token after registration. However, the standalone WorkOnce (line 607), WorkLoop (line 625), Claim (line 514), Renew (line 518), Complete (line 528), and Fail (line 547) subcommands all use the original client built from --api-token. The CLI doc comment at lines 26-33 correctly documents that users must pass the worker-scoped token via SANDBOXWICH_API_TOKEN for these subcommands, but this is a usability concern — a user who copies their tenant token will get auth failures on guest-facing routes with no obvious indication that a different token type is needed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1323 to +1329
fn generate_worker_token() -> String {
format!(
"{WORKER_TOKEN_PREFIX}{}{}",
Uuid::new_v4().simple(),
Uuid::new_v4().simple()
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Worker token entropy relies on two v4 UUIDs rather than a dedicated CSPRNG

The generate_worker_token function at crates/sandboxwich-api/src/main.rs:1323-1329 concatenates two Uuid::new_v4() values (each 122 random bits) for ~244 bits of entropy. This is sufficient for a bearer token, and Uuid::new_v4() uses the OS CSPRNG internally, so the entropy source is sound. The approach is slightly unconventional compared to using rand::thread_rng().gen::<[u8; 32]>() directly, but avoids adding rand as a dependency. Not a bug, just a design choice worth noting.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…st-tokens

# Conflicts:
#	crates/sandboxwich-api/tests/http_contract.rs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines +108 to +115
# GH-64: this tenant-wide credential authenticates only the
# `run` subcommand's initial `POST /workers/register` call.
# Immediately after registering, the worker process mints and
# switches internally to a worker-scoped token bound to its own
# worker id, and uses that (never this value) for every
# guest-facing call it makes afterward (heartbeat, lease
# claim/renew/complete/fail/output) -- those routes reject
# tenant-wide tokens outright.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Deploy YAML comment overstates which routes reject tenant-wide tokens

The comment at deploy/kubernetes/worker.yaml:113-114 says heartbeat routes "reject tenant-wide tokens outright," but the API's heartbeat_worker handler at crates/sandboxwich-api/src/main.rs:2860 only calls ensure_worker_tenant (tenant-level check), not ensure_worker_scope. Heartbeat accepts both tenant-wide and worker-scoped tokens. The code is correct — worker-scoped tokens carry a valid tenant_id so ensure_worker_tenant passes — but the comment is misleading. The same inaccuracy appears in the worker CLI doc comment at crates/sandboxwich-worker/src/main.rs:29.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +3179 to +3183
// GH-64: guest-facing route -- only the worker that provisioned/forked
// this sandbox may report its guest health; tenant-wide tokens are
// rejected. (The read side, `get_guest_health`, stays on tenant auth --
// CLI/dashboard callers need to read it too.)
ensure_sandbox_worker_scope(&state.db, sandbox_id, &ctx).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Guest agent inside sandbox must receive the worker-scoped token to post health updates

The update_guest_health route now calls ensure_sandbox_worker_scope (crates/sandboxwich-api/src/main.rs:3179-3183), which requires a worker-scoped token. The guest agent (crates/sandboxwich-agent/src/main.rs:975) calls this endpoint using whatever SANDBOXWICH_API_TOKEN it was given. The diff doesn't show how the worker injects the worker-scoped token into the sandbox environment during provisioning — this presumably happens in the provider/kubectl layer. If the agent still receives a tenant-wide token (or no token), all guest-health POST calls will fail with 401. This is likely handled outside the diff (in the provisioning templates), but worth confirming.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1163 to +1164
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_token: Option<String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: WorkerResponse struct change is backward-compatible via serde attributes

The new worker_token field on WorkerResponse (crates/sandboxwich-core/src/lib.rs:1163-1164) uses #[serde(default, skip_serializing_if = "Option::is_none")], so existing serialized responses without this field will deserialize cleanly (defaulting to None), and responses where it's None won't include the key in JSON output. This preserves wire compatibility with older clients that don't expect the field.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@haasonsaas
haasonsaas merged commit d4cf7dc into main Jul 9, 2026
9 checks passed
haasonsaas added a commit that referenced this pull request Jul 9, 2026
…in race test

PR #96's worker-scoped guest tokens (merged into main) require lease
claim/renew/complete/fail routes to use a worker-scoped token instead
of the tenant token. assert_job_completion_does_not_resurrect_concurrently_archived_sandbox
predates that change and was still using the tenant client for its
claim/complete calls, which now 401. Switch it to a worker_client for
the registered race_worker, matching every other lease call site in
this file.
haasonsaas added a commit that referenced this pull request Jul 9, 2026
…t-fallout

Fix merge fallout: worker-scoped client for lease race test (post #96/#98)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: guest agent carries a tenant-wide control-plane token inside the untrusted sandbox

1 participant