-
-
Notifications
You must be signed in to change notification settings - Fork 321
fix(ai-sandbox-daytona): keep workspace secrets out of Daytona records #1324
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@tanstack/ai-sandbox-daytona': patch | ||
| '@tanstack/ai-isolate-daytona': patch | ||
| --- | ||
|
|
||
| fix: mount workspace secrets as Daytona organization Secrets so values never land in the sandbox record or command strings |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,10 @@ | |
| * isolation: fs/exec/git operate inside the remote sandbox; paths are real | ||
| * sandbox paths (default workdir `/home/daytona/workspace`). | ||
| * | ||
| * fs and git use the native Daytona SDK. Blocking exec sends env through | ||
| * `executeCommand`'s env argument so secret values never enter the stored | ||
| * fs and git use the native Daytona SDK. Workspace secrets are mounted as | ||
| * Daytona organization Secrets at create (opaque `dtn_secret_*` placeholders | ||
| * in the sandbox env). Blocking exec sends leftover per-call env through | ||
| * `executeCommand`'s env argument so those values never enter the stored | ||
| * command string. Spawn sources a workdir env file for the same reason. | ||
| * | ||
| * NOTE: Daytona's `executeCommand` returns a single combined `result` string | ||
|
|
@@ -151,6 +153,13 @@ export interface DaytonaHandleDeps { | |
| sandbox: Sandbox | ||
| /** Working directory inside the sandbox (the `/workspace` virtual root maps here). */ | ||
| workdir: string | ||
| /** | ||
| * When false, `env.set` does not overlay values onto exec/spawn. Use this | ||
| * after workspace secrets are mounted as Daytona organization Secrets so | ||
| * later `env.set` (bootstrap, resume) cannot put plaintext into command | ||
| * env, the spawn env file, or the process environment. | ||
| */ | ||
| applyEnvSet?: boolean | ||
| } | ||
|
|
||
| export class DaytonaHandle implements SandboxHandle { | ||
|
|
@@ -166,11 +175,13 @@ export class DaytonaHandle implements SandboxHandle { | |
|
|
||
| private readonly sandbox: Sandbox | ||
| private readonly workdir: string | ||
| private readonly applyEnvSet: boolean | ||
| private readonly envVars: Record<string, string> = {} | ||
|
|
||
| constructor(deps: DaytonaHandleDeps) { | ||
| this.sandbox = deps.sandbox | ||
| this.workdir = deps.workdir | ||
| this.applyEnvSet = deps.applyEnvSet ?? true | ||
| this.workspaceRoot = deps.workdir | ||
| this.id = deps.sandbox.id | ||
|
|
||
|
|
@@ -234,7 +245,7 @@ export class DaytonaHandle implements SandboxHandle { | |
|
|
||
| this.env = { | ||
| set: (vars) => { | ||
| Object.assign(this.envVars, vars) | ||
| if (this.applyEnvSet) Object.assign(this.envVars, vars) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Security & Privacy | π Major | ποΈ Heavy lift π§© Analysis chainπ Script executed: sed -n '220,330p' packages/ai-sandbox-daytona/src/handle.tsRepository: TanStack/ai Length of output: 3566 π Script executed: rg -n -A45 -B10 'spawnProcess|persistSpawnEnvFile|mergedEnv' packages/ai-sandbox-daytona/src/handle.tsRepository: TanStack/ai Length of output: 8106 Sensitive Data Exposure (CWE-522): Insufficiently Protected Credentials Do not persist
π€ Prompt for AI Agents |
||
| return Promise.resolve() | ||
| }, | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { Daytona } from '@daytona/sdk' | ||
| import { createHash } from 'node:crypto' | ||
| import { Daytona, DaytonaConflictError } from '@daytona/sdk' | ||
| import { DAYTONA_CAPS, DaytonaHandle } from './handle' | ||
| import type { | ||
| CreateSandboxFromSnapshotParams, | ||
|
|
@@ -51,6 +52,21 @@ function shQuote(value: string): string { | |
| return `'${value.replace(/'/g, `'\\''`)}'` | ||
| } | ||
|
|
||
| /** | ||
| * Organization Secret names must match `^[a-zA-Z_][a-zA-Z0-9_-]*$`. | ||
| * The value hash keeps two different values for the same env key from sharing one Secret. | ||
| */ | ||
| function daytonaOrgSecretName(envKey: string, value: string): string { | ||
| const hash = createHash('sha256').update(value).digest('hex').slice(0, 12) | ||
| const safe = envKey.replace(/[^a-zA-Z0-9_-]/g, '_') | ||
| const body = /^[a-zA-Z_]/.test(safe) ? safe : `k_${safe}` | ||
| return `tanstack_${body}_${hash}` | ||
| } | ||
|
|
||
| function isConflictError(error: unknown): boolean { | ||
| return error instanceof DaytonaConflictError | ||
| } | ||
|
|
||
| class DaytonaProvider implements SandboxProvider { | ||
| readonly name = 'daytona' | ||
| private readonly daytona: Daytona | ||
|
|
@@ -80,15 +96,17 @@ class DaytonaProvider implements SandboxProvider { | |
| */ | ||
| private async wrapCreated( | ||
| sandbox: Awaited<ReturnType<Daytona['create']>>, | ||
| applyEnvSet: boolean, | ||
| ): Promise<SandboxHandle> { | ||
| await sandbox.process.executeCommand(`mkdir -p ${shQuote(this.workdir)}`) | ||
| return new DaytonaHandle({ sandbox, workdir: this.workdir }) | ||
| return new DaytonaHandle({ sandbox, workdir: this.workdir, applyEnvSet }) | ||
| } | ||
|
|
||
| private createParams(input: { | ||
| snapshot?: string | ||
| id?: string | ||
| policy?: SandboxCreateInput['policy'] | ||
| secrets?: Record<string, string> | ||
| }): CreateSandboxFromSnapshotParams { | ||
| return { | ||
| language: this.config.language ?? 'typescript', | ||
|
|
@@ -103,37 +121,62 @@ class DaytonaProvider implements SandboxProvider { | |
| ...(input.policy?.capabilities?.network === 'deny' | ||
| ? { networkBlockAll: true } | ||
| : {}), | ||
| ...(input.secrets !== undefined ? { secrets: input.secrets } : {}), | ||
| } | ||
| } | ||
|
|
||
| private async wrapReady( | ||
| sandbox: Awaited<ReturnType<Daytona['create']>>, | ||
| /** | ||
| * Create-or-reuse organization Secrets and return env-var β secret-name. | ||
| * Empty values are skipped. A 409 means this name (key + value hash) already | ||
| * exists, so the mapping can reuse it. | ||
| */ | ||
| private async ensureOrgSecrets( | ||
| env?: Record<string, string>, | ||
| ): Promise<SandboxHandle> { | ||
| const handle = await this.wrapCreated(sandbox) | ||
| if (env !== undefined) await handle.env.set(env) | ||
| return handle | ||
| ): Promise<Record<string, string> | undefined> { | ||
| if (env === undefined) return undefined | ||
| const secrets: Record<string, string> = {} | ||
| for (const [key, value] of Object.entries(env)) { | ||
| if (value === '') continue | ||
| const name = daytonaOrgSecretName(key, value) | ||
| try { | ||
| await this.daytona.secret.create({ | ||
| name, | ||
| value, | ||
| description: 'TanStack AI workspace secret', | ||
| }) | ||
|
Comment on lines
+142
to
+146
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π Security & Privacy | π Major | ποΈ Heavy lift π§© Analysis chainπ Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- provider config and constructor ---'
sed -n '1,190p' packages/ai-sandbox-daytona/src/provider.ts
printf '%s\n' '--- Daytona package manifests ---'
fd -i 'package.json|pnpm-lock.yaml|yarn.lock|package-lock.json' . | head -80
rg -n -C 3 '"`@daytona/sdk`"|apiUrl|DAYTONA_API_URL' packages/ai-sandbox-daytona package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null || trueRepository: TanStack/ai Length of output: 11851 π€ get_repo_knowledge executed:
Length of output: 8410 π Web query:
π‘ Result: In the Citations:
π Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- lockfiles and SDK references ---'
fd -HI 'pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb|bun.lock' . | head -40
rg -n -C 2 '`@daytona/sdk`|daytona-sdk|apiUrl' . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | head -160Repository: TanStack/ai Length of output: 12564 π Script executed: #!/bin/bash
set -eu
pkg="$(fd -HI -t d '^`@daytona`$' node_modules/.pnpm 2>/dev/null | while read -r d; do
if [ -f "$d/sdk/package.json" ]; then printf '%s\n' "$d/sdk"; fi
done | head -1)"
if [ -z "$pkg" ]; then
pkg="$(fd -HI -t f 'package.json' node_modules/.pnpm 2>/dev/null | while read -r f; do
if rg -q '"name"\s*:\s*"`@daytona/sdk`"' "$f"; then dirname "$f"; fi
done | head -1)"
fi
printf 'SDK package: %s\n' "$pkg"
cat "$pkg/package.json"
printf '%s\n' '--- SDK files mentioning URL/client/redirect ---'
rg -n -C 4 'apiUrl|baseURL|baseUrl|redirect|maxRedirect|axios|fetch\(' "$pkg" --glob '!*.map' | head -260Repository: TanStack/ai Length of output: 29871 π Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- exact 0.192.0 package paths ---'
find node_modules/.pnpm -maxdepth 3 -type f -path '*`@daytona`+sdk@0.192.0*/*/package.json' -print
printf '%s\n' '--- package directories containing exact version ---'
find node_modules/.pnpm -maxdepth 2 -type d -name '*`@daytona`+sdk@0.192.0*' -print
pkg="$(find node_modules/.pnpm -maxdepth 3 -type f -path '*`@daytona`+sdk@0.192.0*/*/package.json' -print -quit | xargs -r dirname)"
if [ -z "$pkg" ]; then
exit 0
fi
printf 'SDK package: %s\n' "$pkg"
cat "$pkg/package.json"
printf '%s\n' '--- exact 0.192.0 constructor/client behavior ---'
rg -n -C 6 'apiUrl|basePath|Authorization|axios\.create|maxRedirects|redirect' "$pkg/cjs/Daytona.js" "$pkg/esm/Daytona.js" "$pkg/cjs" "$pkg/esm" --glob '!*.map' | head -260Repository: TanStack/ai Length of output: 288 π Script executed: #!/bin/bash
set -eu
pkg='node_modules/.pnpm/@daytona+sdk@0.192.0_ws@8.21.0/node_modules/@daytona/sdk'
printf 'SDK package: %s\n' "$pkg"
cat "$pkg/package.json"
printf '%s\n' '--- exact 0.192.0 constructor/client behavior ---'
rg -n -C 6 'apiUrl|basePath|Authorization|axios\.create|maxRedirects|redirect' "$pkg/cjs/Daytona.js" "$pkg/esm/Daytona.js" "$pkg/cjs" "$pkg/esm" --glob '!*.map' | head -300Repository: TanStack/ai Length of output: 44169 Sensitive Data Exposure (CWE-319): Cleartext Transmission of Sensitive Information Reachability: Internal Β· Exploitability: Moderate Reject non-HTTPS
π€ Prompt for AI Agents |
||
| } catch (error) { | ||
| if (!isConflictError(error)) throw error | ||
| } | ||
| secrets[key] = name | ||
| } | ||
| return Object.keys(secrets).length > 0 ? secrets : undefined | ||
| } | ||
|
|
||
| async create(input: SandboxCreateInput): Promise<SandboxHandle> { | ||
| const secrets = await this.ensureOrgSecrets(input.env) | ||
| const sandbox = await this.daytona.create( | ||
| this.createParams({ | ||
| snapshot: this.config.snapshot, | ||
| id: input.id, | ||
| policy: input.policy, | ||
| ...(secrets !== undefined ? { secrets } : {}), | ||
| }), | ||
| ) | ||
| return this.wrapReady(sandbox, input.env) | ||
| // Workspace secrets live in Daytona OS env as placeholders. Do not overlay | ||
| // plaintext via env.set (bootstrap and resume also call env.set). | ||
| return this.wrapCreated(sandbox, secrets === undefined) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π Major | β‘ Quick win Filter
π€ Prompt for AI Agents |
||
| } | ||
|
|
||
| async restoreSnapshot(input: SandboxRestoreInput): Promise<SandboxHandle> { | ||
| const secrets = await this.ensureOrgSecrets(input.env) | ||
| const sandbox = await this.daytona.create( | ||
| this.createParams({ | ||
| snapshot: input.snapshotId, | ||
| policy: input.policy, | ||
| ...(secrets !== undefined ? { secrets } : {}), | ||
| }), | ||
| ) | ||
| return this.wrapReady(sandbox, input.env) | ||
| return this.wrapCreated(sandbox, secrets === undefined) | ||
|
Comment on lines
+128
to
+179
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π― Functional Correctness | π Major | β‘ Quick win Preserve empty assignments in mixed environment maps. π€ Prompt for AI Agents |
||
| } | ||
|
|
||
| async resume(input: SandboxResumeInput): Promise<SandboxHandle | null> { | ||
|
|
@@ -144,7 +187,13 @@ class DaytonaProvider implements SandboxProvider { | |
| if (sandbox.state === 'stopped' || sandbox.state === 'archived') { | ||
| await sandbox.start() | ||
| } | ||
| return new DaytonaHandle({ sandbox, workdir: this.workdir }) | ||
| // Secrets mounted at create stay in the sandbox env as placeholders. | ||
| // applyWorkspaceSecrets would otherwise overlay plaintext on spawn/exec. | ||
| return new DaytonaHandle({ | ||
| sandbox, | ||
| workdir: this.workdir, | ||
| applyEnvSet: false, | ||
| }) | ||
| } catch { | ||
| // Gone / not found. | ||
| return null | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π Security & Privacy | π Major | β‘ Quick win
π§© Analysis chain
π Script executed:
Repository: TanStack/ai
Length of output: 50040
π Script executed:
Repository: TanStack/ai
Length of output: 50370
π€ get_repo_knowledge executed:
get_repo_knowledge TanStack/ai /tmp/coderabbit-repo-knowledge/tanstack-ai-745fe4e5/learnings /tmp/coderabbit-repo-knowledge/tanstack-ai-745fe4e5/conventionsLength of output: 16732
π Web query:
Daytona SDK 0.192.0 organization secrets sandbox environment placeholders outbound HTTPS dashboard environment view create recordπ‘ Result:
Daytona SDK v0.192.0, released on June 26, 2026, introduced organization-scoped secrets management [1][2]. This feature enhances security by preventing plaintext credentials from being exposed within sandbox environments [3]. Organization secrets function by storing encrypted values at the organization level [3]. When a sandbox is created, you map an environment variable to a secret name [3]. Instead of the actual credential, Daytona injects an opaque placeholder token (e.g., dtn_secret_<random_string>) into the sandbox environment [3][4]. When the sandbox initiates an outbound HTTPS request, an outbound proxy inspects the request headers [3]. If a header contains the placeholder and the destination host matches the secret's predefined allowlist, the proxy transparently replaces the placeholder with the decrypted real value before the request reaches the destination [3][5][6]. For any other destination, the placeholder remains unchanged, ensuring the real value is never exposed to unapproved hosts [3]. To create a record of a secret, you can use the Daytona SDK's SecretService, which provides methods for creating, managing, and deleting these credentials [4]. When creating a sandbox, you pass these secrets as a map of environment variable names to secret names within the creation parameters [3][7][4]. This setup allows applications to interact with services securely, as the secrets are only substituted in HTTPS request headers directed toward explicitly allowed hosts [3][5][6].
Citations:
π Web query:
site:daytona.io/docs/en/secrets Daytona organization secrets dashboard sandbox creation response secret value environment variable placeholder redactedπ‘ Result:
In Daytona, secrets are organization-scoped, encrypted credentials designed to be used within sandboxes without exposing sensitive values as plaintext [1]. When creating a sandbox, you map environment variable names to specific secret names [1]. Instead of containing the actual credential, the environment variable is populated with an opaque placeholder token [1]. When your code sends an outbound HTTPS request using this placeholder, Daytona's outbound proxy automatically replaces the placeholder with the real secret value, but only when the request is sent to a pre-defined allowed host [1]. This architecture ensures that sensitive information is never exposed within the sandbox environment, logs, or snapshots [1]. Additionally, secret values are masked in system audit logs to maintain security [1].
Citations:
π Web query:
site:daytona.io/docs/en/secrets Daytona secrets outbound HTTPS allowlist placeholder dashboardπ‘ Result:
Daytona uses a secure mechanism for handling secrets where sensitive credentials are never exposed in plaintext within a sandbox environment [1]. Instead of exposing the actual secret, Daytona injects an opaque placeholder token into the environment [1]. When a sandbox initiates outbound HTTPS traffic, an outbound proxy inspects the request headers [1]. If the request contains this placeholder and the destination host matches an allowlist associated with that secret, the proxy replaces the placeholder with the decrypted, actual secret value before the request reaches the destination [1]. If the destination host is not on the allowlist, the proxy forwards the request with the placeholder unchanged, ensuring the real credential is never transmitted to unauthorized hosts [1]. The allowlist is defined by a hosts array when creating or updating a secret, and it supports subdomains via wildcard matching (e.g., *.example.com) [1]. It is recommended to define an allowlist for every secret to maintain security, as omitting the hosts array leaves the secret unrestricted, allowing the proxy to substitute the real value for requests to any host [1]. Dashboard configuration for these secrets is managed within the Daytona interface where these host arrays are specified [1].
Citations:
Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor
Reachability: External Β· Exploitability: Trivial
Restrict organization secrets to allowed hosts.
ensureOrgSecretscreates each secret without ahostsallowlist. Daytona can then substitute the secret into requests to any HTTPS host, allowing sandbox code to exfiltrate credentials. Pass an explicit allowlist tosecret.createand document that restriction.π€ Prompt for AI Agents