Container Credential Exposure — Beyond Env Injection
Maps credential exposure paths beyond environment injection and compares controls at container and host boundaries.
Research state: Incomplete
Summary
Environment injection is only one exposure path; safe delivery must also constrain files, sockets, process inheritance, and host callbacks.
The threat analysis is established; control selection and implementation commitments remain outside this study.
Research question
jackin❯ credential-forwarding flow injects auth tokens into agent containers via docker run -e KEY=VALUE. This applies uniformly to:
ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN(Claude)OPENAI_API_KEY(Codex)GH_TOKEN,GITHUB_TOKEN,GH_ENTERPRISE_TOKEN(GitHub CLI)- operator env values resolved from literals, host env refs, or
op://1Password references - any future axis the credential source pattern adds
There are two separate risks:
- Runtime introspection risk. Once a token is in the container's process env, several host-side and in-container surfaces expose it to anyone who can inspect the runtime.
- Agent-readable secret risk. Once the agent can read the token, the value can land in chat context, tool logs, shell history, generated files, package-manager config, copied snippets, or a future prompt. Even a trusted agent can accidentally store or exfiltrate it because the value is ordinary text inside its world.
The second risk is the one this roadmap item must solve. Moving a secret from docker run -e to a file is useful, but if the agent can cat that file, jackin❯ has only moved the leak.
Sources
Current research inputs:
- Docker Sandboxes credentials - host-side stored secrets, service identifiers, custom placeholders, OS keychain storage, and proxy-managed credential substitution.
- Docker Sandboxes isolation layers - credential proxy as one of the four isolation layers.
- Docker Sandboxes kits - kit-declared
serviceDomains,serviceAuth, credential sources, and in-VMproxy-managedsentinel environment variables. - 1Password
op run- resolvesop://references and runs one child process with secrets in that subprocess environment. - Doppler secrets access - command-scoped env injection and a warning that environment-variable injection can itself be dangerous.
- Vault Agent templates and Vault Agent env var injection - agent-side rendering, process-supervisor injection, and dynamic secret patterns.
The key lesson: existing tools mostly solve storage and delivery to a process. Docker Sandboxes goes further for HTTP(S) credentials by keeping the raw value on the host and replacing sentinel values at network egress. jackin❯ needs both shapes: per-command delivery for arbitrary CLI/database workflows and proxy-managed delivery for API traffic where the protocol allows it.
Current Exposure Surface
With status-quo env injection, a token can be read from:
| Surface | Token visible |
|---|---|
docker inspect <container> Env field | yes |
docker exec <container> env | yes |
ps auxe of an in-container process (the agent) | yes |
Container filesystem — Sync-mode role-state files (hosts.yml for gh, auth.json for Codex, .credentials.json / account.json for Claude) | yes |
Docker daemon's container-state JSON on disk (/var/lib/docker/...) | yes |
The threat model is "anyone with docker inspect access can read the token," which on macOS Docker Desktop means the operator's UID (low marginal exposure — the operator already has Keychain access to the host's gh token). On Linux the docker group is typically root-equivalent, which broadens the surface.
This is the same pattern the Claude and Codex authentication axes use, so the exposure and its mitigation apply across every authentication axis.
For operator-defined env values, the risk is even broader. A value resolved from op://Work/Database/password and exported as DATABASE_URL is available to the agent for the entire container lifetime. If the agent only needs it for pg_dump, a permanent env var is the wrong shape.
What "good" looks like
A target end state that fully addresses the exposure:
- Tokens never appear in
docker inspectEnv. The container's process env has the token only inside processes that genuinely need it, and only for as long as needed. - Tokens never persist on the container filesystem. No
hosts.yml, no.credentials.json, noauth.jsonleft at rest in the container's writable layer or in jackin❯ role-state directory. - Token rotation on the host (or inside any container) propagates to every consumer within seconds, without restart. (This goal is shared with the Live bidirectional auth sync item.)
- Per-call audit trail. Every credential delivery is logged with timestamp, requester, requested name, approved/denied. Operators can trace "who used my token, when, for what."
- Per-call revocability. Operators can yank a token mid-session and the next request fails immediately, even if other agents in other containers were using it a millisecond ago.
- Named grants only. Ambient credential-shaped variables and sockets such as
GH_TOKEN,GITHUB_TOKEN,SSH_AUTH_SOCK,*_API_KEY, and*_SECRETdo not cross into the container merely because a stack integration or role hints at them. They appear only as explicit credential grants in the session contract. - Agent gets handles, not values. A normal agent tool call cannot print, remember, summarize, or write the secret. It can only ask jackin❯ to use an approved grant in a constrained operation.
- Operation-scoped by default. If the operator approves a database password for
pg_dump, the value exists only in thatpg_dumpprocess environment, stdin, file descriptor, or outbound proxy rewrite. It does not become a container-wide variable.
The host-bridge daemon targets these goals: the agent calls a secret.request MCP tool, the daemon prompts the operator through TouchID or polkit, and an opaque handle substitutes the value into one command without persistent storage. This study captures the alternatives and trade-offs around that canonical model.
Recommended Product Model
Do not think of future secrets as "environment variables that are safer." Think of them as runtime grants.
Suggested vocabulary:
| Concept | Meaning |
|---|---|
credential_source | Where the value comes from: literal, host env, op://, Keychain, Vault, file, command. |
grant | Operator policy allowing a specific workspace/role/agent to request a named secret. |
handle | Opaque token the agent can pass back to jackin❯ but cannot dereference. |
use | One approved operation that consumes the handle: command env, stdin, file descriptor, temp file, or proxy rewrite. |
sink | The allowed destination: command-env, stdin, fd, tempfile, http-header, http-body-placeholder, git-credential, etc. |
Example future operator config:
[secrets.production-db]
source = { op = "op://Work/Prod DB/password" }
default_scope = "single-command"
allowed_sinks = ["command-env", "stdin"]
allowed_commands = ["pg_dump *", "psql *"]
approval = "prompt" # prompt | allowlist | denyExample agent-facing flow:
agent -> secret.request("production-db", reason: "run pg_dump schema-only")
operator -> approves one command
daemon -> returns handle sec_9f13...
agent -> secret.run(handle="sec_9f13...", command="pg_dump --schema-only ...", env={"PGPASSWORD": handle})
daemon/capsule -> spawns pg_dump with PGPASSWORD set only for that process
agent <- stdout/stderr with secret redacted
handle -> invalidatedFor HTTP APIs, prefer Docker Sandboxes-style proxy-managed credentials:
container env: API_KEY=proxy-managed
request header: Authorization: Bearer proxy-managed
host proxy: replaces proxy-managed with the real value only for api.example.com
agent: never sees the real valueFor CLIs and databases, prefer command-scoped injection:
agent: asks to run "PGPASSWORD=$HANDLE pg_dump ..."
jackin: resolves handle and spawns pg_dump with the real env var
agent: sees command output, never sees PGPASSWORDArchitectural options
Seven candidate paths span increasing structural rigor and operational complexity:
1. Status quo — docker run -e KEY=VALUE
In the current posture, the token is visible in docker inspect Env, like other containers that receive secrets from the host.
- Pros: simple; works with every consumer (CLI tools, MCP servers, GitHub-Actions-style scripts read env without ceremony).
- Cons: broadest exposure surface listed above.
- Use case: single-operator local dev, accepted threat model. Documented in Design principles.
2. File-mount (Compose-secrets-style)
jackin❯ writes the token to a tmpfs file on the host, bind-mounts it into the container at a known path (e.g. /run/secrets/gh-token), entrypoint reads the file and either:
- 2a. Re-exports it into env at process startup → tokens hidden from
docker inspectEnv, butdocker exec envstill leaks them once the entrypoint sources. - 2b. Leaves the file as-is and configures the consumer to read the file directly. Works for
gh(file-basedhosts.yml, already happens under Sync) andgit(via!gh auth git-credentialcredential helper). Breaks for consumers that read env without a file fallback (e.g.github-mcp-serverreadsGITHUB_TOKEN).
Trade-offs:
- Pros: clean
docker inspectEnv. Operator pattern matches Docker Composesecrets:stanza, which experienced operators already understand. - Cons: requires a per-consumer credential-helper or env-shim. Doesn't fully eliminate exposure (option 2a) or breaks consumers (option 2b). New mount path to maintain.
- Integration complexity: moderate. Existing
provision_*_authhelpers write files; the launch surface must omit-eflags conditionally and the entrypoint needs a source-from-file shim. - Use case: intermediate stop between status quo and the daemon-based answer.
3. Docker secrets via swarm
Docker's first-class secrets API stores values encrypted at rest in the swarm Raft store and mounts them as files in containers. Available only when the daemon runs in swarm mode.
Trade-offs:
- Pros: standard primitive; mature; encrypted at rest in swarm store.
- Cons: swarm mode is a heavy infrastructure change. jackin❯ launcher uses plain
docker run. Operators don't run swarm for local dev. Migrating is out of scope. - Use case: rejected. Captured here so the option is explicitly considered and ruled out.
4. macOS Keychain bridge over a control socket
A small host-side helper opens the Keychain (operator-authed via Touch ID or login password), exposes credential reads over a Unix domain socket bound at ~/.jackin/run/, and the container reaches it via bind-mount.
Trade-offs:
- Pros: token never leaves macOS Keychain except into the helper's memory and the requesting process's stdin/env.
docker inspectshows nothing. macOS-native crypto (Keychain ACLs, Touch ID gate). - Cons: macOS-specific; Linux hosts need a parallel path (libsecret? plain file?). The helper IS a daemon, so this reduces to "build the daemon" anyway.
- Use case: functionally equivalent to the host-bridge daemon for the macOS side. Captured here as the macOS-specific framing of the same architecture.
5. Per-command secret runner
This is the direct answer to "let the agent run a command with a secret without ever reading the secret."
Shape:
- Agent receives an opaque handle from
secret.request. - Agent calls
secret.run/secret.use_inwith a command template and a list of handle-to-sink bindings. - jackin-capsule or a bridge-controlled wrapper spawns the command.
- The real value is injected only into that child process through env, stdin, a file descriptor, or a short-lived temp file.
- stdout/stderr are redacted before they return to the agent.
- The handle is invalidated when the process exits.
Trade-offs:
- Pros: solves database passwords, one-off API calls,
curl,psql,pg_dump,aws,gcloud, and similar command workflows; maps well to the operator's approval mental model. - Cons: requires runtime cooperation. If the agent can run an arbitrary shell outside the wrapper with the secret handle, it still cannot resolve the handle, but it can ask for more approvals. Some tools insist on config files and will need a short-lived file sink.
- Use case: command-scoped secret delivery; depends on bridge transport.
6. Host-side credential proxy
Docker Sandboxes' credential model is the best reference here. The agent receives a sentinel value such as proxy-managed or a generated placeholder; outbound HTTP(S) traffic goes through a host-side proxy; the proxy rewrites an auth header or placeholder only for allowed target domains.
Trade-offs:
- Pros: best shape for model-provider API keys, GitHub API tokens, and service APIs. The raw credential never enters the container or the agent's process memory.
- Cons: only fits traffic the proxy can see and understand. It does not help
psql, arbitrary TCP protocols, local CLIs that sign requests internally, or tools that validate token format before making a request unless placeholder compatibility is designed. - Use case: canonical answer for agent/provider API credentials and any HTTP(S) integration jackin❯ can model declaratively.
TLS interception reality (clarified from May 2026 research): for HTTPS endpoints the proxy must MITM the TLS connection — terminate with its own CA cert and re-encrypt to the upstream. That requires:
- a jackin❯-controlled CA cert installed in the role environment's trust store (system bundle + per-runtime stores: Node
NODE_EXTRA_CA_CERTS, Pythonrequests.certifi, JVMcacerts, etc.) - TLS 1.3 ECH compatibility — if the upstream supports Encrypted Client Hello, the proxy needs to either downgrade the negotiation (visible) or speak ECH itself
- handling for tools that pin cert fingerprints (rare for CLI tools but real for some SDKs); these workflows must opt out of the proxy and use the per-command runner path instead
The intuition that libkrun's TSI on the smolvm backend could do this without an in-guest cooperating proxy is wrong. TSI operates at the socket syscall layer — it sees raw bytes after the guest establishes TLS. See the smolvm research for the TSI Limits section. The credential proxy needs a real HTTP proxy + trusted CA inside the runtime, regardless of backend.
Threat model for handles themselves: the proxy approach moves the secret out of the agent's process memory but introduces handles (or proxy-managed sentinels) into the agent's environment and chat history. A hostile agent can:
- enumerate operator credential inventory by spam-requesting
secret.request("aws_prod"),secret.request("github_admin"), etc., and observing which trigger an operator prompt vs immediate error. Mitigation: error parity for hidden-vs-denied — same response shape, same latency. - copy a live handle into a file or chat for later use. Mitigation: handles are scoped per requester/session, expire on a short TTL (suggested: 5 minutes idle, 1 hour absolute), and are invalidated on agent process restart.
- ask the proxy to use a handle for a different domain than approved. Mitigation: handles are bound to the approved domain + protocol at issuance; proxy refuses other uses.
7. Host-bridge daemon (canonical control plane)
Per the host-bridge roadmap and the jackin❯ daemon umbrella:
- jackin❯ daemon runs on the operator's host, holds a per-operator Unix socket.
- An auto-registered MCP server inside every agent container exposes
secret.request(name, scope, reason)andsecret.use_in(template)tools. - Agent calls the tool → MCP server forwards request to daemon over the socket → daemon prompts operator (Touch ID / polkit / password) → daemon resolves the secret from the operator's chosen source (Keychain / 1Password / etc.) → returns an opaque handle to the agent.
- The handle is consumed in exactly one command (
secret.use_in/secret.run) or one proxy-managed outbound request. The runtime substitutes the handle's value into the spawned process's env/stdin/fd/tempfile, or the proxy substitutes it into the outbound request. The substitution is invisible to the agent's chat history, tool output, and tracing. - Container env stays empty for credentials.
docker inspect,docker exec env, container fs all clean.
MCP server topology is recursive — flagged here because the design easily glosses over it. The secret.request MCP server runs inside the role container, alongside the agent. The host-bridge daemon runs on the macOS/Linux host, outside the container. The MCP server cannot reach the daemon's Unix socket directly: the container does not see the host's filesystem. Two transport options:
| Transport | Shape | Trade-off |
|---|---|---|
| Bind-mount the daemon socket | -v /Users/<op>/.jackin/sockets/host-bridge.sock:/jackin/run/host-bridge.sock | Simplest. The socket appears at a /jackin/-convention path inside the container. Survives container restart. Requires the bind to be set up at launch time, and the OrbStack/smolvm backends to support socket bind mounts. |
| vsock or TCP loopback proxy | jackin-capsule opens a vsock or 127.0.0.1:<port> listener inside the container, proxies bytes to the host daemon over an out-of-band channel (capsule's existing control socket at /jackin/run/jackin.sock). | Works for backends that cannot bind-mount a host socket (smolvm, future Kubernetes). Adds an in-container proxy hop. |
For the Docker backend, bind-mount is the V1 answer. For backends without socket bind support, the proxy-through-capsule path is required and adds a secret-broker axis to the existing jackin-capsule control protocol.
Handle lifecycle on agent restart: when the agent process crashes or restarts (Claude Code reconnect, codex hardline, etc.), live handles must be invalidated. The MCP server cannot survive without the agent it serves. Decision: handles are bound to the MCP-server session ID; restart issues a new session ID; old handles fail-closed with a clear error. No silent re-issuance.
Per-call audit log location: ~/.jackin/data/<instance>/credential-bridge.jsonl per the host-path convention. Per-instance, not global, so purge cleanly removes the log alongside the instance's other state. A global mirror is out of scope; operators who want cross-instance audit can grep across ~/.jackin/data/*/credential-bridge.jsonl. Earlier proposals named ~/.jackin/log/credential-bridge.jsonl (global); that is wrong because it survives purge and creates a long-lived cross-instance secret-name inventory.
Trade-offs:
- Pros: structurally addresses every exposure surface listed in the Problem section. Same daemon hosts other reactive features (live auth sync, agent attention prompts) so the cost amortizes.
- Cons: large architectural lift. Depends on the daemon's lifecycle, installation, control-socket, and security contract. Tools that read env directly, especially MCP servers, need a runtime-level shim that converts handles to ephemeral env at process spawn or a proxy-managed sentinel flow.
- Use case: canonical answer; the jackin❯ daemon owns delivery.
Design implications
- The launch and session contract distinguishes agent-readable environment values from brokered secrets.
- A
secretorsensitiveclassification lets jackin❯ warn when a value is exported container-wide while preserving its configured source for brokered delivery. - File mounts apply only to compatible consumers and remain agent-readable, so they are not a complete security boundary.
- The per-command runner composes
secret.request, operator approval, an opaque handle, andsecret.runorsecret.use_infor one child process. - HTTP(S) service credentials fit a host-side proxy with runtime sentinels, host-custodied values, domain-scoped rewriting, audit logs, and network-policy integration.
- Environment injection remains a compatibility fallback for secrets; ordinary non-secret configuration remains suitable for environment variables.
Recommended default:
| Value type | Default delivery |
|---|---|
Non-secret config (ORG, REGION, FEATURE_FLAG) | Container env. |
Existing agent auth compatibility (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) | Env or file today, proxy-managed when available. |
| Database passwords and one-off tool secrets | Per-command handle/runner. |
| API tokens for HTTP(S) services | Credential proxy with sentinel values. |
| SSH keys | Do not copy; if ever supported, use agent forwarding with explicit opt-in and audit. |
| Long-lived persistent secret files | Avoid; require explicit operator opt-in and launch-contract warning. |
Out of scope for this item
- SSH keys. jackin❯ deliberately does not forward SSH keys (authentication overview). This item covers token-style credentials only.
- Cross-host credential injection for remote or Kubernetes-hosted agents requires a separate transport and trust model.
- Operator-to-container credential delivery for non-secret config (e.g.
GH_HOSTis operator-set but not sensitive; pass-through env is fine and stays).
Limitations and open questions
Classification and broker model
- Classifying existing env entries. Should every operator env value default to
visibility = "agent-readable"and require an explicitvisibility = "brokered"for secrets, or should jackin❯ infer likely secrets from names such as*_TOKEN,*_PASSWORD,*_KEY, andDATABASE_URLand prompt the operator to convert them? - 1Password UX. A configured
op://reference currently means "resolve at launch and export." Future config needs a way to mean "store this source, but do not resolve until a specific operation is approved." The source should remain the same; only the delivery mode changes. - Command matching. How strict should
allowed_commandsbe? Shell strings are hard to match safely. A better shape may require argv arrays (["pg_dump", "--schema-only", "--dbname", "..."]) and reject shell metacharacters unless the operator explicitly allows a shell. - Redaction boundary. If a command prints the secret to stdout, jackin❯ can redact exact values it injected. It cannot redact derived values, base64 encodings, hashes, or values the command writes to mounted files. The session contract must say that.
- Handle leakage. Handles are not secrets, but an agent can copy a live handle into files or chat. Handles must be short-lived, scoped to one requester/session, and unusable from a different container or after timeout.
- Prompt fatigue. If every
npm installorawscall asks for approval, operators will disable the feature. The design needs bounded allowlists, reusable grants, and clear audit output without making broad persistent secrets the easy path.
File-mount intermediate stop and daemon integration
- Per-consumer shim. A file-mount approach needs a runtime-level shim that reads
/run/secrets/<name>and either re-exports it at agent-process spawn or configures the consumer to read the file.ghandgitsupport file or helper paths;github-mcp-serverrequires an environment value. The agent runtime's process-spawn API is the natural substitution seam. - Daemon adapter contract. What does
secret.request(name)return when the operator denies? Structured error vs. silent None? The MCP-server-side abstraction for the agent has to be uniform across credentials whose existence the operator denies vs. credentials they explicitly forbid for the workspace. - Audit log persistence. Where does the per-call delivery log live?
~/.jackin/log/credential-bridge.jsonlwith rotation? Operator-readable / -searchable? Same place as the host-bridge audit log, or separate? - Compose-style mount for token-mode env values. Today
[…github.env].GH_TOKENresolves at launch time to a String the launcher pushes via-e. In the file-mount path, that resolved String would land in the tmpfs file instead. The launcher would need to choose which secrets go to env vs. file — likely driven by a per-consumer registry (MCP servers want env,ghis file-happy,gitis helper-happy).
Why this is one item, not many
The exposure exists across every auth axis (Claude / Codex / GitHub). The mitigations (file-mount, daemon bridge, Keychain integration) all apply uniformly. Designing each axis's escape from env injection separately would produce three different shapes — same anti-pattern the jackin❯ daemon umbrella exists to prevent. One item, one design pass, three downstream adapters.
Related work
- jackin❯ daemon — umbrella for the long-running host process the canonical fix depends on.
- Host bridge — sibling item; the
secret.requestflow is the user-visible shape of option 5 above. - Live bidirectional auth sync — sibling item; the daemon also keeps host and container in lock-step on token rotation, which combined with this item's "tokens never persist in the container" goal eliminates token drift entirely.
- Credential source pattern — unified credential resolver; this item's per-consumer registry plugs into that source model.
- Docker runtime hardening contract — Docker profiles choose whether secrets are exported, brokered, or denied.
- Selectable sandbox backends — every backend must report whether credentials are agent-readable, command-scoped, or proxy-managed.
- Design principles — repo-wide design principles. The exposure surface this item addresses sits inside the "Container is the trust boundary, not the prompt" principle: jackin❯ shrinks the boundary further by reducing what crosses into the container's env.