Container Credential Exposure — Beyond Env Injection
Status: Open — design proposal
Problem
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.
Source Material
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 Claude / Codex auth axes already use, so the GitHub auth feature didn't introduce a new posture — but landing it brought the question into focus, and a long-term answer benefits every auth axis at once.
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 item already proposes a flow that targets these goals — the agent calls a secret.request MCP tool, the daemon prompts the operator (TouchID / polkit), the value is delivered as an opaque handle that the runtime substitutes into one command, and nothing lands at rest. That is the long-term answer. This item exists to capture the trade-offs and brainstorm intermediate stops along the way.
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 PGPASSWORDImplementation strategies — trade-off survey
Seven candidate paths, in increasing order of "structural rigor" and "implementation cost":
1. Status quo — docker run -e KEY=VALUE
What ships today. Token visible in docker inspect Env. Same posture as every container that takes secrets from the host today (which is most of them).
- 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.
- Implementation effort: moderate. Existing
provision_*_authhelpers already write files; the launch surface needs to drop the-eflags conditionally and the entrypoint needs the 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: high-priority implementation target after the bridge transport exists.
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. Requires the daemon's lifecycle / install / control-socket / security posture to be designed first (the umbrella item). Per-consumer integration: tools that read env directly (MCP servers especially) need a runtime-level shim that converts handles to ephemeral env at process spawn, or a proxy-managed sentinel flow.
- Use case: the canonical answer. Phase 3 of the jackin❯ daemon implementation phasing.
Decision direction
The recommended trajectory:
- Now. Document that operator env and
op://values are resolved into long-lived container env today. Make the launch/session contract distinguish "agent-readable env" from future "brokered secret." - Near-term. Add a
secret/sensitiveclassification to operator env entries so jackin❯ can warn when a secret is being exported container-wide and can later route that same configured value to brokered delivery without changing the source. - Medium-term. Land option 2 (file-mount) only for consumers that support it and where it reduces host-side introspection. Do not call file-mount a complete fix because the agent can still read the file.
- Medium-term. Build the per-command secret runner on top of the host bridge:
secret.request→ operator approval → opaque handle →secret.run/secret.use_in→ one child process. - Long-term. Build a host-side credential proxy for HTTP(S) service credentials, using Docker Sandboxes as the reference model: sentinel values inside the runtime, real values on the host, domain-scoped rewrite, audit log, and network policy integration.
- Long-term. Make env injection the compatibility fallback, not the preferred secret path. Normal non-secret configuration can remain env forever; secrets should move to brokered grants.
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 (remote agent) credential injection. The Kubernetes phase of jackin❯ will need its own version of this story — out of scope here.
- 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).
Open design 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. The medium-term file-mount approach needs a runtime-level shim that reads
/run/secrets/<name>and either (a) re-exports as env at agent-process spawn or (b) configures the consumer to read the file. Which consumers benefit from (a) vs (b)?ghis (b) today;gitis (b) via gh's helper;github-mcp-serverwould need (a). Is there a clean place for jackin to inject (a) without modifying every MCP server? The agent runtime's process spawn API is the natural seam — Claude Code and Codex both spawn child processes through their tool-use loop; instrumenting the spawn side would let jackin substitute env from a file at exec time. - 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).
Experiment Plan
-
Document current risk in launch output.
- Mark every resolved operator env key as
agent-readable. - For likely secret names, show a warning and link to this roadmap item.
- Mark every resolved operator env key as
-
Prototype command-scoped injection locally.
- Add a development-only wrapper that runs
op run -- <command>from the host for a single command. - Confirm it solves the
pg_dump/database-password case without exporting the password into the container. - Document why plain
op runis not enough inside the container: it still gives the child process the value and requires the agent to invoke the wrapper correctly.
- Add a development-only wrapper that runs
-
Prototype opaque handles inside Capsule.
- Build a fake secret provider returning
SecretString. - Expose
secret.requestandsecret.runin a local MCP server. - Verify the agent only sees a handle and redacted output.
- Build a fake secret provider returning
-
Prototype Docker Sandboxes-style HTTP proxy.
- Use a sentinel env value such as
proxy-managed. - Route one test domain through a host proxy.
- Replace the auth header only for that domain.
- Verify the real token is absent from container env, filesystem, and agent-visible output.
- Use a sentinel env value such as
-
Compare UX.
- Run the same workflow with status-quo env, file mount, command-scoped injection, and proxy-managed injection.
- Score each on compatibility, accidental leak risk, operator prompts, and debuggability.
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 proxy (existing roadmap line) — earlier idea about proxy-based credential injection; the host-bridge /
secret.requestflow is the operator-mediated answer to the same problem. - Credential source pattern — future unified credential resolver. This item's per-consumer registry (which secrets go to env vs file vs daemon-handle) plugs into that resolver.
- Docker runtime hardening contract — Docker profiles should eventually 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.