feat: add credential substitution for sandboxed environments#63
Merged
Conversation
Detects credential env vars (100+ well-known names + suffix patterns), generates opaque placeholders, registers them with greyproxy's session API, and rewrites the sandbox environment. Real credentials are injected transparently at the proxy's MITM layer. Includes: - Credential detection with well-known list and suffix pattern matching - Session registration, heartbeat loop, and cleanup on exit - Environment variable substitution with placeholders - Deny rules for session.key and ca-key.pem in both Linux (bwrap) and macOS (Seatbelt) sandboxes - --no-credential-protection flag to opt out - Unit tests for detection, substitution, and placeholder generation
Add --cred <LABEL> flag (repeatable) to inject global credentials stored in the greyproxy dashboard. At session creation, requested labels are sent via global_credentials field; the proxy resolves them and returns placeholders. Greywall sets the placeholder as an env var in the sandbox. SubstituteEnv now appends env vars that don't already exist, so --cred works even when the variable is not in the host environment. Requires greyproxy with global_credentials support in POST /api/sessions.
When --cred is used, verify greyproxy is running and is v0.3.4 or later (the version that adds global_credentials support to POST /api/sessions). Shows a clear error with upgrade instructions if the check fails.
Allows protecting env vars that don't match the well-known list or suffix patterns (e.g. LITELLM_NOTRACK_API_KEY). The flag is repeatable: greywall --protect MY_CUSTOM_VAR --protect ANOTHER_VAR -- command Unlike --cred (which fetches values from the proxy dashboard), --protect works with values already in the host environment.
… support
Rename flags for clarity:
--cred -> --inject (inject credential from proxy dashboard)
--protect -> --secret (mark env var as a secret)
Add credentials section to config file so these can be set per-profile:
{
"credentials": {
"secrets": ["LITELLM_NOTRACK_API_KEY"],
"inject": ["ANTHROPIC_API_KEY"]
}
}
Config values are merged with CLI flags (deduplicated).
5 tasks
The greyproxy dashboard can display context about what command is running in each sandbox session. Pass working directory, command name, arguments, binary path, and PID when registering and re-registering sessions.
Allow users to exclude specific env vars from credential detection. Useful for variables that match suffix patterns (e.g. _TOKEN) but are not actually secrets. Can be set via --ignore-secret CLI flag or credentials.ignore in the config file. Both sources are merged and deduplicated.
Previously, .env files were masked (bind-mounted as empty) inside the sandbox, breaking tools that read .env files at runtime. Now, when credential substitution is active, .env files are rewritten with placeholder values and mounted read-only into the sandbox. Each .env file gets unique per-file placeholders so that different files with the same key but different values (e.g., .env has KEY=a, .env.local has KEY=b) each map to the correct real value on the proxy side. Environment variable substitution uses only env-detected mappings to avoid placeholder collisions. When credential substitution is not active (disabled or registration failed), the old empty-file masking behavior is preserved with a warning printed to stderr.
tito
added a commit
to GreyhavenHQ/greyproxy
that referenced
this pull request
Mar 27, 2026
## Summary
End-to-end credential substitution: real API keys never reach sandboxed
processes. The proxy transparently replaces opaque placeholders with
real values before forwarding HTTP requests upstream.
### Global credentials
Global credentials are stored in the dashboard and injected on demand
via `--inject`:
```bash
greywall --inject ANTHROPIC_API_KEY --inject OPENAI_API_KEY -- opencode
```
The session creation API (`POST /api/sessions`) accepts a
`global_credentials` field (list of labels). The proxy resolves each
label, merges placeholder-to-value mappings into the session, and
returns the placeholders so greywall can set them as environment
variables.
**Example request:**
```json
{
"session_id": "gw-abc123",
"container_name": "opencode",
"global_credentials": ["ANTHROPIC_API_KEY"],
"ttl_seconds": 900
}
```
**Example response:**
```json
{
"session_id": "gw-abc123",
"expires_at": "2026-03-25T23:00:00Z",
"credential_count": 1,
"global_credentials": {
"ANTHROPIC_API_KEY": "greyproxy:credential:v1:global:a1b2c3..."
}
}
```
### Substitution tracking
- Substitution counts flushed to DB every 60s, broadcast via WebSocket
(`session.substitution` event)
- Activity and traffic tables show shield icon for substituted requests,
credential labels as badges in expanded details
- Session cards show creation time and active duration
### Changes
- **API**: `POST /api/sessions` accepts `global_credentials`, resolves
labels, returns placeholders
- **Credential store**: publishes `session.substitution` events after
flushing counts
- **Activity**: `ActivityItem` includes `SubstitutedCredentials`; shield
icon and label badges in both activity and traffic tables
- **Settings UI**: global credentials section explains `--inject`
workflow and substitution behavior
- **Tests**: CRUD, API handler, and end-to-end substitution tests for
global credentials
- **Docs**: `docs/credential-substitution.md` covering the full API,
substitution behavior, and dashboard UI
## Test plan
- [x] `go test ./internal/greyproxy/...` passes
- [x] `go test ./internal/greyproxy/api/...` passes (session creation
tests)
- [x] `greywall --inject LABEL -- env` shows placeholder in output
- [x] HTTP request with placeholder substituted (shield icon in activity
view)
- [x] Settings > Credentials > Active Sessions shows substitution count
updating
## Dependencies
Companion greywall PR: GreyhavenHQ/greywall#63
- credential-protection.md: add .env file rewriting section explaining full Linux support (bind-mount) vs macOS limitations (files denied) - platform-support.md: add credential substitution rows to feature comparison table and macOS-specific explanation - Update limitations section to reflect current state
- errcheck: wrap resp.Body.Close() in deferred closure to handle return value - gosec G117: rename SessionMetadata.Pwd to WorkDir to avoid secret pattern match - gosec G704/G703: add nolint annotations for local API calls and temp file cleanup - Add RewrittenEnvFiles field to LinuxSandboxOptions stub (linux_stub.go) to fix macOS compilation error
- gofumpt: align struct literal fields in main.go - gosec G704: move nolint annotation to http.DefaultClient.Do() call
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end credential protection for sandboxed commands. Real API keys are replaced with opaque placeholders before entering the sandbox; greyproxy transparently injects real values into HTTP requests before they reach upstream servers.
Auto-detection
Greywall scans environment variables against 100+ well-known names and suffix patterns (
_API_KEY,_TOKEN,_SECRET,_PASSWORD). Detected credentials are automatically protected.greywall -- opencode # credentials auto-detected and protected--secretand--injectflagsConfig file support
Flags can also be set in the config file or per-profile:
{ "credentials": { "secrets": ["LITELLM_NOTRACK_API_KEY"], "inject": ["ANTHROPIC_API_KEY"] } }Session lifecycle
Sandbox hardening
session.keyandca-key.pemdenied inside the sandbox on both Linux (bwrap) and macOS (Seatbelt).Changes
internal/sandbox/credentials.go: credential detection, placeholder generation, session registration withglobal_credentialssupport, heartbeat loop,SubstituteEnv(replaces + appends)internal/sandbox/credentials_test.go: detection, substitution, suffix patterns, extra vars, env appendinginternal/config/config.go:CredentialConfigwithsecretsandinjectfields, merge supportcmd/greywall/main.go:--secret,--inject,--no-credential-protection,--skip-version-check; config merging; greyproxy version check (>= v0.3.4 for--inject)internal/sandbox/dangerous.go: greyproxy sensitive file pathsinternal/sandbox/{linux,macos}.go: deny-read rules for key filesdocs/credential-protection.md: full documentationDependencies
Requires greyproxy PR: GreyhavenHQ/greyproxy#26
The
global_credentialsfield inPOST /api/sessionsis implemented there. Auto-detected credentials and--secretwork with any greyproxy version;--injectrequires >= v0.3.4.Test plan
go test ./...passesgreywall -- envshows placeholders for detected credentialsgreywall --secret CUSTOM_VAR -- envshows placeholder for the custom vargreywall --inject LABEL -- envshows placeholder for injected credentialgreywall --no-credential-protection -- envshows real valuescredentials.secretsandcredentials.injectmerged with CLI flags