# jackin-exec design rationale (https://jackin.tailrocks.com/research/platform/security/credential-exposure/jackin-exec-design/)



**Research state:** Reference

## Summary [#summary]

Credentials should be resolved on demand and released only to the authorized child command, never the ambient capsule environment.

This rationale describes the current `jackin-exec` mechanism; [Secure execution wrapper](/roadmap/jackin-exec/) owns delivery work.

## What This Is [#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.

The mechanism works on top of any jackin❯ backend. See [Agent isolation architecture](/research/platform/security/isolation-architecture/agent-isolation-architecture/) for the complete picture.

## The Problem [#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](/research/platform/security/credential-exposure/container-credential-exposure/) for the full exposure-surface analysis this item is one mitigation for.

## Two credential tiers in one `env` map [#two-credential-tiers-in-one-env-map]

The `on_demand` flag on `EnvValue` changes when and whether a value is injected without introducing a separate configuration section.

### Tier 1 — always available by default [#tier-1--always-available-by-default]

```toml
[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 [#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.

```toml
[env]
# Always available:
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.

## Configuration model [#configuration-model]

The configuration types live in <RepoFile path="crates/jackin-core/src/env_value.rs" />.

### `Extended` values [#extended-values]

```rust
#[serde(untagged)]
pub enum EnvValue {
    OpRef(OpRef),       // discriminated by `op` field — must stay first
    Extended(Extended), // discriminated by `value` field
    Plain(String),      // scalar fallback — must stay last
}
```

```rust
#[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,
}
```

```rust
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:**

```rust
// 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:**

```rust
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:**

```rust
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 <RepoFile path="crates/jackin-env/src/resolve.rs" />, launch-time env resolution skips `on_demand` vars before container env injection:

```rust
// 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.

### `on_demand` on `OpRef` [#on_demand-on-opref]

```rust
pub struct OpRef {
    pub op: String,
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub account: Option<String>,
    #[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 [#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).

All resolved values are injected as environment variables on the spawned command's `CommandBuilder`, using the binding name as the key (for example, `GH_TOKEN=resolved_value`). The command inherits only those selected variables plus its standard environment. Token-style credentials work directly. Raw SSH private-key strings cannot be consumed by `ssh` as environment variables; key-file materialization is outside the current architecture and belongs to the [Secure execution wrapper roadmap item](/roadmap/jackin-exec/).

**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 [#host-process-callback]

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 [#hostsock-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):

```json
{
  "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):

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

**Error response**:

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

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

The `jackin load` host process stays running for the session lifetime. Its `host.sock` listener runs alongside the interactive backend attach. The socket at `~/.jackin/sockets/<container>/host.sock` is exposed inside the container as `/jackin/run/host.sock`. Requests resolve configured 1Password references or host variables, return values to the capsule, and inject them only into the selected child command.

**Boundary caveat.** The picker is the operator-facing approval. The host resolver enforces an allow-list of operator-configured on-demand bindings, never arbitrary references, but any in-container process that can reach `/jackin/run/host.sock` can request that configured set without using the picker. Stronger peer binding or one-time authorization belongs to the [Host bridge](/roadmap/host-bridge/) and [jackin❯ daemon](/roadmap/jackin-daemon/) security boundary.

## Binary: Symlink + Subcommand [#binary-symlink--subcommand]

`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):

```rust
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`):

```rust
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 <RepoFile path="crates/jackin-protocol/src/control.rs" />:**

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

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

// In ServerMsg — 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 [#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` [#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 (<RepoFile path="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.

## Supporting code touchpoints [#supporting-code-touchpoints]

### Runtime, picker, and host socket [#runtime-picker-and-host-socket]

**Source files:**

* <RepoFile path="crates/jackin-capsule/src/exec.rs">crates/jackin-capsule/src/exec.rs</RepoFile> — exec client: argv\[0] detection, connect to control channel, send ExecCommand, receive result
* <RepoFile path="crates/jackin-runtime/src/exec_host.rs" /> — host.sock listener: stays alive for session duration, runs `op read`, responds to capsule credential requests
* <RepoFile path="crates/jackin-core/src/env_value.rs" /> — `Extended` variant on `EnvValue`; `on_demand: bool` on `OpRef`
* <RepoFile path="crates/jackin-capsule/src/main.rs" /> — `exec` subcommand dispatch + argv\[0] detection
* <RepoFile path="crates/jackin-protocol/src/control.rs" /> — `ExecCommand`, `ExecResult`, `ExecDenied`
* <RepoFile path="crates/jackin-capsule/src/daemon.rs" /> — dispatches `ExecCommand`; pushes `Dialog::ExecPicker(ExecPickerState)` onto the daemon's `dialog_stack`. `ExecPickerState` shape:

  ```rust
  pub struct ExecPickerState {
      pub command: String,
      pub args: Vec<String>,
      pub items: Vec<ExecPickerItem>,
      pub cursor: usize,
  }

  pub struct ExecPickerItem {
      pub name: String,       // env var name e.g. "GH_TOKEN"
      pub display: String,    // OpRef.path or Extended.value — human label
      pub kind: ExecItemKind,
      pub source: String,     // op:// URI, $VAR name, or literal
      pub selected: bool,
  }

  pub enum ExecItemKind { Op, Env, Literal }
  ```

  When `ExecCommand` arrives: build `ExecPickerState` from workspace on-demand vars (read from the capsule's loaded config), push onto dialog\_stack. Dialog handles Space=toggle, ↑↓=cursor, Enter=confirm (read selected items → connect to `/jackin/run/host.sock` → resolve → inject → execute), Esc=cancel (send `ExecDenied`).
* <RepoFile path="crates/jackin-capsule/src/socket.rs" /> — routes `ExecCommand` in the control dispatcher
* <RepoFile path="crates/jackin-runtime/src/runtime/launch.rs" /> + <RepoFile path="crates/jackin-runtime/src/runtime/launch/launch_pipeline.rs" /> — starts the host.sock listener for Docker launches and injects `JACKIN_EXEC_BINDINGS=name1,name2,...` as an always-available env var. The `is_on_demand()` filtering happens upstream in <RepoFile path="crates/jackin-env/src/resolve.rs" /> (`resolve_operator_env_with_matching` drops on-demand vars before resolution, so they are never `op read` at launch), with `collect_on_demand_bindings` collecting the names beside it
* <RepoFile path="crates/jackin-runtime/src/runtime/apple_container.rs" /> — passes the host.sock path and `JACKIN_EXEC_BINDINGS` through the apple-container launch path
* <RepoFile path="docker/runtime/entrypoint.sh" /> — uses `JACKIN_EXEC_BINDINGS` for the system prompt block

### MCP surface [#mcp-surface]

* Exposes `jackin_exec` MCP tool from jackin-capsule using the same exec subcommand handler. Lives in <RepoFile path="crates/jackin-capsule/src/mcp_server.rs" /> and is registered from <RepoFile path="crates/jackin-capsule/src/runtime_setup.rs" />.

## Code touchpoints [#code-touchpoints]

* <RepoFile path="crates/jackin-core/src/env_value.rs" /> — `EnvValue` enum and `OpRef` configuration model
* <RepoFile path="crates/jackin-capsule/src/main.rs" /> — `exec` subcommand
* <RepoFile path="crates/jackin-capsule/src/daemon.rs" /> — ExecCommand dispatch, picker overlay, host.sock callback, temp env injection, output filtering
* <RepoFile path="crates/jackin-protocol/src/control.rs" /> — ExecCommand + ExecResult + ExecDenied
* <RepoFile path="crates/jackin-runtime/src/runtime/launch.rs" /> — host.sock listener + `JACKIN_EXEC_BINDINGS`
* <RepoFile path="crates/jackin-runtime/src/exec_host.rs" /> — host-side credential resolver
* <RepoFile path="docker/runtime/entrypoint.sh" /> — system prompt injection for `JACKIN_EXEC_BINDINGS`

## Related work [#related-work]

* Roadmap: [jackin-exec: Secure Execution Wrapper](/roadmap/jackin-exec/)
* [Agent isolation architecture](/research/platform/security/isolation-architecture/agent-isolation-architecture/) — master architecture doc; jackin-exec is Layer 3
* [Container credential exposure](/research/platform/security/credential-exposure/container-credential-exposure/) — the problem this item solves
* [jackin❯ daemon](/roadmap/jackin-daemon/) — shared host-service boundary
* [Host bridge — secrets and approved host actions](/roadmap/host-bridge/) — stronger authorization and credential-resolution boundary
