> ## Documentation Index
> Fetch the complete documentation index at: https://vvennela-ftl-18-95.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Shadow credentials

> How FTL replaces real API keys with fake values inside the sandbox

Shadow credentials are fake API keys that FTL generates and injects into the sandbox. The agent writes code using these fake values, and your real keys never enter the container.

## How it works

When you run `ftl code`, FTL loads credentials from your `.env` file and generates shadow values before booting the sandbox:

**1. Load real credentials**

FTL reads all key-value pairs from `.env` in your project root:

```python theme={null}
# From credentials.py:70-91
def load_real_keys(project_path, extra_vars=None):
    real_keys = {}

    # Everything in .env is sensitive — use python-dotenv for robust parsing
    env_file = Path(project_path) / ".env"
    if env_file.exists():
        parsed = dotenv_values(env_file)
        for key, value in parsed.items():
            if value:
                real_keys[key] = value

    # Extra vars from .ftlconfig pulled from os.environ
    for key in (extra_vars or []):
        if key not in real_keys and key in os.environ:
            real_keys[key] = os.environ[key]

    return real_keys
```

> [!NOTE]
>
> You can specify additional environment variable names in `.ftlconfig` under `"shadow_env"`. FTL will pull these from `os.environ` on the host.

**2. Generate shadow values**

For each real credential, FTL generates a recognizable fake value using `secrets.token_hex(8)`:

```python theme={null}
# From credentials.py:64-68
def generate_shadow_key(name):
    """Generate a recognizable shadow key for a credential."""
    token = secrets.token_hex(8)
    return f"{SHADOW_PREFIX}{name.lower()}_{token}"
```

The prefix is `ftl_shadow_`, followed by the lowercased variable name and a 16-character hex token.

**Example:**

```
STRIPE_SECRET_KEY=sk_live_abc123
→ ftl_shadow_stripe_secret_key_7f8a2b3c

OPENAI_API_KEY=sk-proj-xyz789
→ ftl_shadow_openai_api_key_4d9e2a1f
```

**3. Build swap table**

FTL creates two data structures:

* **shadow\_env:** `{VAR_NAME: shadow_value}` — injected into the container as environment variables
* **swap\_table:** `{shadow_value: real_value}` — used by the network proxy to swap credentials at runtime

```python theme={null}
# From credentials.py:94-111
def build_shadow_map(project_path, extra_vars=None):
    real_keys = load_real_keys(project_path, extra_vars)

    shadow_env = {}
    swap_table = {}

    for name, real_value in real_keys.items():
        shadow_value = generate_shadow_key(name)
        shadow_env[name] = shadow_value
        swap_table[shadow_value] = real_value

    return shadow_env, swap_table
```

**4. Inject into container**

Shadow credentials are written to a file inside the container (`/tmp/.ftl_env`) and sourced before agent commands:

```python theme={null}
# From sandbox/docker.py:196-206
all_env = {**self._credentials, **self._agent_env}
if all_env:
    env_lines = "\n".join(f"export {k}='{v}'" for k, v in all_env.items())
    subprocess.run(
        ["docker", "exec", self.container_id, "sh", "-c",
         f"cat > {ENV_FILE} << 'FTLEOF'\n{env_lines}\nFTLEOF"],
        capture_output=True,
    )
```

When the agent or its generated code runs `os.getenv("STRIPE_SECRET_KEY")`, it receives the shadow value.

## Network proxy

By default, the agent sees only shadow values. If it makes an API call using `ftl_shadow_stripe_secret_key_...`, the request will fail because Stripe doesn't recognize that key.

To enable live API calls, install the network proxy:

```bash theme={null}
pip install -e ".[proxy]"
```

The proxy intercepts all outbound HTTP/HTTPS traffic from the container and swaps shadow values back to real keys **before they reach the upstream server**:

```python theme={null}
# From proxy.py:135-140
def _swap(self, data: bytes) -> bytes:
    """Replace shadow credential bytes with real credential bytes."""
    for shadow, real in self.server.swap_table.items():
        if shadow.encode() in data:
            data = data.replace(shadow.encode(), real.encode())
    return data
```

**Flow:**

**1. Agent code makes request**

```python theme={null}
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# stripe.api_key = "ftl_shadow_stripe_secret_key_7f8a2b3c"
```

**2. Request goes through proxy**

The container routes all traffic through `http://host.docker.internal:<port>` (configured via `HTTP_PROXY` env var).

```python theme={null}
# From proxy.py:397-409
def env_vars(self):
    no_proxy = f"{self._NO_PROXY_BASE},{self._AGENT_DOMAINS}"
    return {
        "HTTP_PROXY": self.url,
        "HTTPS_PROXY": self.url,
        "http_proxy": self.url,
        "https_proxy": self.url,
        "NO_PROXY": no_proxy,
        "NODE_EXTRA_CA_CERTS": "/tmp/ftl-proxy-ca.crt",
    }
```

**3. Proxy swaps credentials**

```
Container sends:  Authorization: Bearer ftl_shadow_stripe_secret_key_7f8a2b3c
Proxy rewrites:   Authorization: Bearer sk_live_abc123
```

**4. Upstream receives real key**

```
Stripe receives:  Authorization: Bearer sk_live_abc123  ✓
```

The API call succeeds. The agent never learns the real key — it only sees the response.

### HTTPS and MITM

For HTTPS traffic, the proxy performs a man-in-the-middle attack using an ephemeral CA:

```python theme={null}
# From proxy.py:45-82 (simplified)
def _generate_ca():
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.x509.oid import NameOID

    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    name = x509.Name([
        x509.NameAttribute(NameOID.COMMON_NAME, "FTL Proxy CA"),
        x509.NameAttribute(NameOID.ORGANIZATION_NAME, "FTL"),
    ])
    now = datetime.now(timezone.utc)
    cert = (
        x509.CertificateBuilder()
        .subject_name(name)
        .issuer_name(name)
        .public_key(key.public_key())
        .not_valid_before(now)
        .not_valid_after(now + timedelta(days=1))
        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
        .sign(key, hashes.SHA256())
    )
```

The CA is installed in the container's trust store before the agent runs:

```python theme={null}
# From proxy.py:377-395
def install_ca_in_container(self, sandbox):
    import base64
    cert_b64 = base64.b64encode(self.ca_cert_pem).decode()
    cmds = (
        # System CA store (curl, Python requests, etc.)
        f"echo '{cert_b64}' | base64 -d"
        f" > /usr/local/share/ca-certificates/ftl-proxy.crt"
        f" && update-ca-certificates -q"
        # Node.js CA file (Claude Code, any other Node apps)
        f" && echo '{cert_b64}' | base64 -d > /tmp/ftl-proxy-ca.crt"
    )
    sandbox.exec_as_root(cmds)
```

> [!NOTE]
>
> The CA key is never written to disk. It's generated in memory at proxy startup and discarded when the proxy stops.

### Proxy exclusions

The agent's own API calls (e.g., Claude Code calling `api.anthropic.com`) bypass the proxy to avoid SSL verification issues:

```python theme={null}
# From proxy.py:365-373
_AGENT_DOMAINS = (
    "api.anthropic.com,"           # Claude Code
    "*.amazonaws.com,"             # Kiro / Bedrock
    "amazonaws.com,"
    "api.openai.com,"              # in case agent uses OpenAI directly
    "oauth2.googleapis.com,"       # Google auth
    "accounts.google.com"
)
```

These domains are added to `NO_PROXY` so traffic goes direct.

## Lint detection

Before showing the diff, FTL scans for hardcoded shadow values:

```python theme={null}
# From lint.py:13-14, 96-109
SHADOW_PATTERN = re.compile(r"ftl_shadow_\w+_[0-9a-f]{16}")

for tag, content in diff["lines"]:
    if tag != "+":
        continue
    if SHADOW_PATTERN.search(content):
        violations.append(LintViolation(
            path, line_num, content,
            "Hardcoded shadow credential value"
        ))
        continue

    for sv in shadow_values:
        if sv in content:
            violations.append(LintViolation(
                path, line_num, content,
                "Hardcoded shadow credential value"
            ))
```

This catches cases where the agent writes:

```python theme={null}
# BAD: hardcoded shadow value
stripe.api_key = "ftl_shadow_stripe_secret_key_7f8a2b3c"
```

The agent should always use environment variables:

```python theme={null}
# GOOD: reads from env
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
```

> [!WARNING]
>
> Shadow credentials are **not** a substitute for read-only or test API keys. They prevent accidental leaks, but if the proxy is enabled, the agent can still make live API calls using your real keys.

## AWS Secrets Manager integration

When `secrets_manager_prefix` is set in `.ftlconfig`, FTL fetches credentials from AWS Secrets Manager instead of reading `.env`:

```python theme={null}
# From orchestrator.py:146-158
sm_prefix = self.config.get("secrets_manager_prefix", "")
if sm_prefix:
    from ftl.secrets import load_from_secrets_manager
    from ftl.credentials import generate_shadow_key
    sm_secrets = load_from_secrets_manager(sm_prefix)
    self.shadow_env, swap_table = {}, {}
    for name, real_value in sm_secrets.items():
        shadow_value = generate_shadow_key(name)
        self.shadow_env[name] = shadow_value
        swap_table[shadow_value] = real_value
```

Secrets with JSON object values are expanded into individual keys:

```json theme={null}
// AWS Secrets Manager: /myproject/prod/db
{
  "DB_HOST": "postgres.example.com",
  "DB_PASSWORD": "hunter2"
}

// Becomes inside container:
DB_HOST=ftl_shadow_db_host_1a2b3c4d
DB_PASSWORD=ftl_shadow_db_password_5e6f7g8h
```

Plain-string secrets use the last path segment as the key name, uppercased:

```
// AWS Secrets Manager: /myproject/prod/STRIPE_KEY
"sk_live_abc123"

// Becomes inside container:
STRIPE_KEY=ftl_shadow_stripe_key_9i0j1k2l
```

## Configuration reference

```json theme={null}
// .ftlconfig
{
  "shadow_env": ["MY_EXTRA_SECRET"],           // Pull from os.environ on host
  "secrets_manager_prefix": "/myproject/prod/", // AWS mode (replaces .env)
}
```

> [!NOTE]
>
> If `secrets_manager_prefix` is set, `.env` is ignored. All secrets must come from AWS.

## Security considerations

* **Shadow values are logged:** FTL prints the count of shadow credentials but never logs the values themselves
* **Proxy CA is ephemeral:** The CA key is generated in memory and never written to disk. It's valid for 24 hours and discarded when the proxy stops
* **Swap table lives in memory:** The `{shadow: real}` mapping is stored in the proxy process, not in the container or on disk
* **Container sees only shadows:** Even if you `docker exec` into a running container, you'll only find shadow values in `/tmp/.ftl_env`

> [!WARNING]
>
> If you enable CloudWatch or Langfuse tracing, ensure your logging policies redact credentials. FTL does not send shadow values to external services, but agent output may contain them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.