ResearchSecurityCredential Exposure

jackin-exec: Design Rationale and Implementation Record

Status: Implemented — this is the design record for the shipped jackin-exec on-demand credential mechanism; see jackin-exec: Secure Execution Wrapper for current roadmap status and remaining work.

What This Is

jackin-exec is a subcommand of jackin-capsule. The agent calls jackin-exec ssh sentry; under the hood this is jackin-capsule exec ssh sentry. Before the command runs, the operator sees a picker dialog in the TUI listing every on_demand = true env var configured for that workspace. The operator selects which credentials to inject, presses Enter, the host resolves them via op read, and the command runs with those values injected ephemerally. Raw credential values never appear in the agent's process environment.

jackin-exec is a symlink to jackin-capsule — no separate binary, no separate release artifact, no separate build job.

Works on top of any jackin backend. Does not require the Apple Container backend to land first. See Agent isolation architecture for the complete picture.

The Problem

Current jackin model injects all credentials as container env vars at docker run time. Every process inside the container can call printenv and see raw API keys. The operator wants credentials available to commands without being permanently visible to the agent. See Container Credential Exposure — Beyond Env Injection for the full exposure-surface analysis this item is one mitigation for.

Two Tiers — Same env Map, One New Flag

The on_demand flag extends the existing EnvValue enum. No new sections, no migration required for existing configs. The flag simply changes when and whether a value is injected.

Tier 1 — always-available (default, unchanged)

[env]
PUBLIC_ENDPOINT = "https://api.example.com"                                  # plain literal
BUILD_ENV       = "${BUILD_ENV}"                                              # host env expansion
ADMIN_KEY       = { op = "op://abc.../def.../fld...", path = "Work/Admin/key" }  # 1Password

Injected into the container at docker run time. Agent sees via printenv. Current behavior.

Tier 2 — on-demand

Add on_demand = true to any env value. The env var is not injected at launch, does not appear in printenv, and is only materialized during a jackin-capsule exec call when the operator explicitly selects it in the picker.

[env]
# Always-available (unchanged):
PUBLIC_ENDPOINT = "https://api.example.com"
ADMIN_KEY       = { op = "op://abc.../def.../fld...", path = "Work/Admin/key" }

# On-demand — not in container env, only via picker:
API_KEY  = { op = "op://abc.../def.../fld...", path = "Work/Anthropic/api key", on_demand = true }
SSH_KEY  = { op = "op://abc.../def.../fld...", path = "Personal/SSH/key",        on_demand = true }
GH_TOKEN = { value = "$GH_TOKEN",             on_demand = true }
STG_URL  = { value = "https://staging.internal", on_demand = true }

Toggle behavior: when the operator sets on_demand = false (or removes it) via the TUI config editor, jackin serializes back to the compact plain form:

  • { value = "https://staging.internal", on_demand = false }"https://staging.internal"
  • { op = "...", path = "...", on_demand = false }{ op = "...", path = "..." }

When toggled to on_demand = true, jackin serializes to the table form. This conversion happens in the config editor only — no data migration required.

Rust Type Changes

All type changes live in crates/jackin-core/src/env_value.rs.

1. New Extended variant in EnvValue

#[serde(untagged)]
pub enum EnvValue {
    OpRef(OpRef),       // discriminated by `op` field — must stay first
    Extended(Extended), // NEW — discriminated by `value` field
    Plain(String),      // scalar fallback — must stay last
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Extended {
    pub value: String,
    #[serde(default)]
    pub on_demand: bool,
}
fn default_on_demand() -> bool { false }

Serde untagged discrimination order:

  1. OpRef has #[serde(deny_unknown_fields)] — rejects { value = ... } (unknown field) → falls through
  2. Extended has #[serde(deny_unknown_fields)] — rejects anything without value field → falls through
  3. Plain(String) — scalar fallback

{ value = "..." } without on_demand defaults to false; on-demand values must opt in explicitly.

Exhaustive match methods — must add Extended arm to all of them:

// as_persisted_str(): returns the unexpanded source string
EnvValue::Extended(e) => e.value.as_str(),

// as_display_str(): same — display the value string
EnvValue::Extended(e) => e.value.as_str(),

resolve_env_value launch-time behavior — new arm:

EnvValue::Extended(e) => {
    if e.on_demand {
        // on_demand=true: never resolved at launch; filtered out before
        // container env injection (see is_on_demand() below).
        // This arm should not be reached during normal launch.
        Err(anyhow::anyhow!("{layer_label} env var {var_name:?}: on_demand var reached resolve_env_value — this is a bug"))
    } else {
        // on_demand=false: treat as Plain
        dispatch_plain(layer_label, var_name, &e.value, host_env)
    }
}

Method on EnvValue — used by launch to skip on-demand vars:

impl EnvValue {
    /// Returns true if this env var should NOT be injected at container launch.
    /// on_demand vars are resolved at exec time via jackin-capsule exec instead.
    pub fn is_on_demand(&self) -> bool {
        match self {
            Self::OpRef(r) => r.on_demand,
            Self::Extended(e) => e.on_demand,
            Self::Plain(_) => false,
        }
    }
}

In crates/jackin-env/src/resolve.rs, launch-time env resolution skips on_demand vars before container env injection:

// Before building -e flags, filter out on_demand vars:
let launch_env = resolved_env.vars.iter()
    .filter(|(_, v)| !v.is_on_demand())
    // ... existing rest of loop

On-demand vars are still read from the workspace config (to build JACKIN_EXEC_BINDINGS), but they are not passed as -e flags to docker run.

Extended { on_demand: false } runtime behavior: treated as always-available, resolves via dispatch_plain at launch. Config editor prevents this state by converting back to Plain(String) when toggling off, but the runtime arm above handles it defensively.

2. Add on_demand to OpRef

pub struct OpRef {
    pub op: String,
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub account: Option<String>,
    // NEW:
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub on_demand: bool,   // false = always-available (current behavior), true = on-demand
}

skip_serializing_if = "std::ops::Not::not" means: omit from TOML when false. Existing OpRef tables without on_demand parse with on_demand = false — no change to behavior. Only when on_demand = true does the field appear in the serialized TOML.

The Picker Dialog

Every jackin-exec call triggers the picker. No pre-selection, no patterns. All on_demand = true vars appear. Operator picks what they need, presses Enter.

┌─ Select credentials to attach ────────────────────────────────────┐
│  Command: ssh sentry                                               │
│                                                                    │
│  [ ] API_KEY    Work/Anthropic/api key       (1Password)          │
│  [ ] SSH_KEY    Personal/SSH/key             (1Password)          │
│  [ ] GH_TOKEN   host env: $GH_TOKEN                              │
│  [ ] STG_URL    https://staging.internal     (literal)            │
│                                                                    │
│  Space: toggle  ↑↓: navigate  Enter: execute  Esc: cancel        │
└────────────────────────────────────────────────────────────────────┘

OpRef values show path (human-readable display). Extended values show their value string. No credential values are shown — display is always the label or source, not the resolved secret.

After Enter: capsule sends the selected refs to the host process → host resolves via op read for each selected 1Password ref, or reads host env for $VAR refs → values sent back to capsule → injected as env vars on the spawned command's CommandBuilder → command runs → output scanned for leaked values → process exits (env vars die with it, no explicit cleanup needed).

Credential injection mechanism (Phase 1): All resolved values are injected as environment variables on the CommandBuilder for the spawned command — using the binding name as the key (e.g., GH_TOKEN=resolved_value, AWS_ACCESS_KEY_ID=resolved_value). The command inherits only these specific injected vars plus its standard env. This works for token-style credentials (GitHub, AWS, API keys). Known Phase 1 limitation: raw private key strings (SSH private keys from op read) cannot be consumed directly by the ssh binary via an env var. Phase 1 documents this: if an operator selects an SSH key credential, it is injected as SSH_KEY=<pem_content>. The agent must write it to a temp file itself or use GIT_SSH_COMMAND. Temp-file materialization for key-type credentials (write to /jackin/run/secrets/<uuid>, set SSH_KEY_FILE=/jackin/run/secrets/<uuid>, delete after command exits) remains open — see the roadmap item's remaining work.

Edge cases:

  • No on-demand vars configured (JACKIN_EXEC_BINDINGS is empty): jackin-exec runs the command immediately without showing the picker. No dialog, no delay.
  • host.sock unavailable (host process died or listener failed): capsule returns ExecDenied { reason: "host credential resolver unavailable" }. Agent sees the error message. No silent failure.
  • op read fails (1Password auth expired, item not found): host.sock returns an error for that specific ref. Capsule returns ExecDenied { reason: "credential resolution failed: <op error message>" }.
  • op Touch ID / biometric prompt: blocks the host.sock listener task until the OS prompt completes. This is expected and intentional — the user explicitly wanted the 1Password window to appear on their macOS host.

Host Process Callback (Phase 1 Design)

On-demand credentials are resolved at exec time, not at container launch. Capsule (inside the container) calls back to the host to resolve credentials.

host.sock Protocol

Length-prefixed JSON (same framing as the control channel: [4-byte BE length][JSON body]). One request/response exchange per jackin-exec invocation.

Request (capsule → host):

{
  "refs": [
    { "name": "GH_TOKEN",  "kind": "op",  "source": "op://uuid/uuid/uuid" },
    { "name": "SSH_KEY",   "kind": "op",  "source": "op://uuid/uuid/uuid" },
    { "name": "BUILD_ENV", "kind": "env", "source": "$BUILD_ENV" },
    { "name": "STG_URL",   "kind": "literal", "source": "https://staging.internal" }
  ]
}

kind: "op" → call op read <source>; "env" → read host env var from source; "literal" → return source verbatim (no host call needed).

Response (host → capsule):

{
  "values": {
    "GH_TOKEN":  "ghp_xxxx",
    "SSH_KEY":   "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
    "BUILD_ENV": "production",
    "STG_URL":   "https://staging.internal"
  }
}

Error response:

{ "error": "op read failed for GH_TOKEN: biometric auth cancelled" }

On error, capsule sends ExecDenied to the agent — no command is executed.

Phase 1 mechanism: jackin load host process stays running for the lifetime of the session. After the role container starts, the host immediately attaches to it through the selected backend, which blocks for the interactive session. The host.sock listener runs as a separate task alongside that attach call. The socket path ~/.jackin/sockets/<container>/host.sock is bind-mounted into the container at /jackin/run/host.sock for Docker and passed through the apple-container launch path when that backend is selected. When capsule sends a resolution request, the host task reads it, runs op read for each 1Password ref, reads host env vars, writes values back. Capsule injects them ephemerally and executes.

This is the Phase 1 ad-hoc design. The host process listening on a socket is not a daemon — it is the jackin load process staying alive. Future migration: the jackin daemon (Host bridge, jackin daemon) will handle all credential resolution requests from all running containers. The per-process host.sock approach will be retired when the daemon ships.

Boundary caveat. The picker is the operator-facing approval, enforced daemon-side. The host resolver on host.sock enforces an allow-list — it resolves only the operator-configured on-demand bindings, never arbitrary refs — but it does not yet enforce per-use approval: /jackin/run/host.sock is reachable by any in-container process, so a compromised agent could connect directly and resolve the configured set without a picker. Binding the socket to the daemon (SO_PEERCRED / a one-time per-confirm token) is remaining hardening tracked on the roadmap item; the future jackin daemon that replaces this ad-hoc listener subsumes it.

jackin-exec is handled by jackin-capsule argv dispatch. The symlink/install surface is part of the capsule runtime setup rather than a separate binary or release artifact.

When capsule is invoked as jackin-exec (argv[0] check) or with exec as first arg, it routes to the exec subcommand. The dispatch in main.rs (after the PID-1 and prepare-commit-msg checks):

Some("exec") | None if invoked_as_jackin_exec(&args) => exec::run(&args).await,

invoked_as_jackin_exec function body (follows the pattern of invoked_as_prepare_commit_msg_hook):

fn invoked_as_jackin_exec(args: &[String]) -> bool {
    args.first()
        .and_then(|a| std::path::Path::new(a).file_name())
        .is_some_and(|n| n == "jackin-exec")
}

The None branch (no subcommand, but invoked as jackin-exec) makes jackin-exec <command> [args...] work — args[0] is jackin-exec, remaining args are the command.

Control protocol variants in crates/jackin-protocol/src/control.rs:

ClientMsg and ServerMsg both have an #[serde(other)] Unknown sink variant that must remain last. New variants were inserted before Unknown:

// In ClientMsg — added before Unknown:
ExecCommand { command: String, args: Vec<String> }

// In ServerMsg — added before Unknown:
ExecResult { exit_code: i32, stdout: String, stderr: String, redacted_count: u32 }
ExecDenied { reason: String }

stdout/stderr are lossy UTF-8 strings. Capsule caps each stream and redacts selected secret values before returning the result.

Output Filtering

All resolved credential values (returned from op read or host env) are added to the redaction set for that command's output. Capsule scans stdout + stderr before returning to the agent:

  • Exact value match → [redacted by jackin❯]
  • PEM blocks → [key material redacted by jackin❯]

Full unredacted output logged to diagnostics run file under JACKIN_DEBUG.

How the Agent Learns to Use jackin-exec

JACKIN_EXEC_BINDINGS is injected as an always-available container env var at launch — listing the names of all on-demand vars (not their values):

JACKIN_EXEC_BINDINGS=API_KEY,SSH_KEY,GH_TOKEN,STG_URL

The entrypoint (docker/runtime/entrypoint.sh) uses this to append a system prompt block for every agent runtime. Also exposed as a jackin_exec MCP tool for Claude Code / Codex, registered only when JACKIN_EXEC_BINDINGS is non-empty.

Schema Migration Footprint

SurfaceFile kindType touchedAction
EnvValue::Extended (new variant) + OpRef.on_demand (new field)~/.config/jackin/workspaces/<name>.tomlWorkspaceConfigEnvValuecarried by the current workspace migration chain, landing at v1alpha8 with fixtures through crates/jackin/tests/fixtures/migrations/workspace/from-v1alpha7/meta.toml

Both changes are additive with serde defaults. Existing TOML files parse identically.

Implementation Phases (historical record)

Phase 1 — exec subcommand + picker + host.sock resolution

Source files:

Phase 2 — MCP tool

On this page