jackin-exec design rationale
Records the threat model and architecture for releasing credentials only to an authorized on-demand command.
Research state: Reference
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 owns delivery 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.
The mechanism works on top of any jackin❯ backend. 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 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
[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" } # 1PasswordInjected 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:
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
The configuration types live in crates/jackin-core/src/env_value.rs.
Extended values
#[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
}#[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:
OpRefhas#[serde(deny_unknown_fields)]— rejects{ value = ... }(unknown field) → falls throughExtendedhas#[serde(deny_unknown_fields)]— rejects anything withoutvaluefield → falls throughPlain(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:
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 loopOn-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
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
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.
Edge cases:
- No on-demand vars configured (
JACKIN_EXEC_BINDINGSis empty):jackin-execruns 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 readfails (1Password auth expired, item not found): host.sock returns an error for that specific ref. Capsule returnsExecDenied { reason: "credential resolution failed: <op error message>" }.opTouch 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
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.
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 and jackin❯ daemon security boundary.
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):
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. Exec variants appear before Unknown:
// 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
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_URLThe 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.
Supporting code touchpoints
Runtime, picker, and host socket
Source files:
-
crates/jackin-capsule/src/exec.rs— exec client: argv[0] detection, connect to control channel, send ExecCommand, receive result -
crates/jackin-runtime/src/exec_host.rs— host.sock listener: stays alive for session duration, runsop read, responds to capsule credential requests -
crates/jackin-core/src/env_value.rs—Extendedvariant onEnvValue;on_demand: boolonOpRef -
crates/jackin-capsule/src/main.rs—execsubcommand dispatch + argv[0] detection -
crates/jackin-protocol/src/control.rs—ExecCommand,ExecResult,ExecDenied -
crates/jackin-capsule/src/daemon.rs— dispatchesExecCommand; pushesDialog::ExecPicker(ExecPickerState)onto the daemon'sdialog_stack.ExecPickerStateshape: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
ExecCommandarrives: buildExecPickerStatefrom 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 (sendExecDenied). -
crates/jackin-capsule/src/socket.rs— routesExecCommandin the control dispatcher -
crates/jackin-runtime/src/runtime/launch.rs+crates/jackin-runtime/src/runtime/launch/launch_pipeline.rs— starts the host.sock listener for Docker launches and injectsJACKIN_EXEC_BINDINGS=name1,name2,...as an always-available env var. Theis_on_demand()filtering happens upstream incrates/jackin-env/src/resolve.rs(resolve_operator_env_with_matchingdrops on-demand vars before resolution, so they are neverop readat launch), withcollect_on_demand_bindingscollecting the names beside it -
crates/jackin-runtime/src/runtime/apple_container.rs— passes the host.sock path andJACKIN_EXEC_BINDINGSthrough the apple-container launch path -
docker/runtime/entrypoint.sh— usesJACKIN_EXEC_BINDINGSfor the system prompt block
MCP surface
- Exposes
jackin_execMCP tool from jackin-capsule using the same exec subcommand handler. Lives incrates/jackin-capsule/src/mcp_server.rsand is registered fromcrates/jackin-capsule/src/runtime_setup.rs.
Code touchpoints
crates/jackin-core/src/env_value.rs—EnvValueenum andOpRefconfiguration modelcrates/jackin-capsule/src/main.rs—execsubcommandcrates/jackin-capsule/src/daemon.rs— ExecCommand dispatch, picker overlay, host.sock callback, temp env injection, output filteringcrates/jackin-protocol/src/control.rs— ExecCommand + ExecResult + ExecDeniedcrates/jackin-runtime/src/runtime/launch.rs— host.sock listener +JACKIN_EXEC_BINDINGScrates/jackin-runtime/src/exec_host.rs— host-side credential resolverdocker/runtime/entrypoint.sh— system prompt injection forJACKIN_EXEC_BINDINGS
Related work
- Roadmap: jackin-exec: Secure Execution Wrapper
- Agent isolation architecture — master architecture doc; jackin-exec is Layer 3
- Container credential exposure — the problem this item solves
- jackin❯ daemon — shared host-service boundary
- Host bridge — secrets and approved host actions — stronger authorization and credential-resolution boundary