jan-cli 0.27.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
# Jan runtime pool — security hardening implementation process
#
# Normative process for closing the gaps identified in the warm-runner
# security evaluation. This is an implementation roadmap, not a jan command tree.
#
# Baseline assumption (unchanged): preferred YAML is trusted like PATH.
# These steps harden the *pool* (IPC, cross-job leakage, DoS), not sandbox
# untrusted trees.
#
# Usage: follow phases in order; do not skip exit_criteria. Mark items done
# in git commits that reference phase ids (e.g. `runtime-sec/P2`).

meta:
  id: jan-runtime-security-hardening
  title: Runtime pool security hardening
  status: planned
  version: "1.0"
  created: "2026-09-04"
  applies_to:
    - jan-cli/src/runtime_daemon.rs
    - jan-cli/runtime/python_worker.py
    - jan-cli/runtime/node_worker.js
    - jan-cli/runtime/shell_worker.py
    - jan-cli/runtime/kotlin_worker.py
    - jan-cli/docs/cli/runtime.md
    - jan-cli/docs/security.md
  related:
    - docs/security.md
    - docs/cli/runtime.md
    - docs/maintainers/architecture.md
  non_goals:
    - Sandboxing preferred-tree code (seccomp, landlock, containers)
    - Cross-user multi-tenancy on a shared host
    - Publisher authentication for YAML trees
    - Removing warm Node soft-isolation entirely (strict mode is opt-in)

threat_model:
  in_scope:
    - same_uid_local_attacker: process that can open XDG_RUNTIME_DIR sockets
    - malicious_or_buggy_leaf: script in preferred tree that tries to poison later jobs
    - secret_exposure: env.private / pass values on the job IPC path
    - dos: hung worker, socket spam, oversized payloads
  out_of_scope:
    - attacker_with_different_uid
    - kernel_bugs
    - already_compromised_preferred_tree_as_sole_goal  # already full RCE under UID

principles:
  - id: P1
    text: Same-UID sockets are not a trust boundary; add an explicit shared-secret token anyway to raise the bar and stop casual clients.
  - id: P2
    text: Default remains fast (warm Node soft-isolation). Strict mode prefers isolation over speed.
  - id: P3
    text: Secrets must not travel as a full env JSON blob when avoidable.
  - id: P4
    text: Every job must have a hard timeout enforced by the worker host.
  - id: P5
    text: Worker keys must include jan-use / spec identity so trees cannot share a poisoned isolate by accident.
  - id: P6
    text: Documentation must state Node-warm ≠ process isolation.

defaults:
  socket_mode: "0600"
  idle_ttl_secs: 900
  max_workers: 16
  max_job_request_bytes: 16777216
  default_job_timeout_ms: 1800000
  strict_env: JAN_RUNTIME_STRICT
  disable_env: JAN_RUNTIME
  token_file: runtime.token
  control_sock: runtime.sock

# ---------------------------------------------------------------------------
phases:
  - id: P0
    name: Spec lock-in and metrics baseline
    goal: >
      Record current behavior and acceptance tests so hardening cannot regress
      warm-path correctness or silently change isolation semantics.
    tasks:
      - id: P0.1
        title: Inventory sockets and protocol
        steps:
          - Document control commands (ping, status, ensure, stop, note_warm, note_cold).
          - Document job request/response JSON schema as of HEAD.
          - List artifacts under XDG_RUNTIME_DIR/jan-cli/ (socks, pid, future token).
        deliverables:
          - docs/maintainers/runtime-ipc.md
      - id: P0.2
        title: Capture timing and isolation baselines
        steps:
          - Record warm vs cold Node/Python ms/run on a reference machine (debug + release).
          - Add a failing-or-skipped test checklist for cross-job Node require.cache pollution (expected under default warm).
        deliverables:
          - Comment or appendix in docs/cli/runtime.md with baseline table
      - id: P0.3
        title: Security doc stub
        steps:
          - Add a "Runtime pool" subsection to docs/security.md pointing at this process and stating non-sandbox status.
        deliverables:
          - docs/security.md updated
    exit_criteria:
      - IPC inventory merged
      - Baseline timings recorded
      - security.md mentions the pool

  - id: P1
    name: Socket authentication (token)
    goal: >
      Require a per-session secret for all supervisor and worker RPC so random
      same-UID clients cannot inject jobs without reading the token file.
    depends_on: [P0]
    design:
      token_generation: >
        Supervisor creates a 32-byte random token at start, writes
        $RUNTIME_DIR/runtime.token with mode 0600, unlinks on stop.
      client_presentation: >
        First line or JSON field `auth` must equal the token for control and job
        sockets. Mismatch → close with error, no job execution.
      ensure_flow: >
        jan client reads token from disk (same UID); passes auth on ensure and
        on worker job connect.
    tasks:
      - id: P1.1
        title: Supervisor token lifecycle
        files: [src/runtime_daemon.rs]
        steps:
          - Generate token in run_foreground; write token file; remove in remove_runtime_files.
          - Reject control commands without valid auth (except optionally a local ping that still requires auth — prefer all-or-nothing).
        acceptance:
          - Without token file readable, ensure fails closed
          - stop still works for the owning jan using the token
      - id: P1.2
        title: Worker protocol auth
        files:
          - runtime/python_worker.py
          - runtime/node_worker.js
          - runtime/shell_worker.py
          - runtime/kotlin_worker.py
        steps:
          - Require `auth` field on every job JSON matching env JAN_RUNTIME_AUTH injected at worker spawn (supervisor passes token via env, not argv).
          - Reject missing/wrong auth before fork/eval.
        acceptance:
          - Crafted job without auth does not execute user code
      - id: P1.3
        title: Client wiring
        files: [src/runtime_daemon.rs, src/lib.rs]
        steps:
          - send_command / ensure_worker / run_job_on_worker attach auth.
        acceptance:
          - Existing smoke test runtime_help_and_warm_python_node still passes
      - id: P1.4
        title: Tests
        steps:
          - Integration test: wrong auth → non-zero / cold fallback / explicit error under JAN_RUNTIME_DEBUG.
          - Integration test: missing token file → warm path unavailable, cold works.
    exit_criteria:
      - All pool RPCs authenticated
      - Tests green
      - docs/cli/runtime.md documents token file location

  - id: P2
    name: Hard job timeouts
    goal: Bound hung scripts so one leaf cannot stall the Node queue or a Python child forever.
    depends_on: [P1]
    design:
      timeout_source: >
        JobRequest.timeout_ms from jan (default defaults.default_job_timeout_ms);
        optional future YAML exec.timeout — out of scope unless already exists.
      enforcement:
        python: signal.alarm or communicate timeout then kill process group
        node: Promise.race + worker terminate / Atomics; on timeout terminate Worker and respawn persistent isolate
        shell_kotlin: subprocess timeout then kill
    tasks:
      - id: P2.1
        title: Plumb timeout_ms in JobRequest
        files: [src/runtime_daemon.rs, src/lib.rs]
      - id: P2.2
        title: Enforce in each worker
        files:
          - runtime/python_worker.py
          - runtime/node_worker.js
          - runtime/shell_worker.py
          - runtime/kotlin_worker.py
        acceptance:
          - Job that sleeps longer than timeout returns non-zero and frees the worker for the next job within 2s
      - id: P2.3
        title: Node respawn-after-timeout
        steps:
          - After terminate, spawnWorker() before accepting next job.
    exit_criteria:
      - Timeout test for python and node
      - Default timeout documented

  - id: P3
    name: Worker key includes spec identity
    goal: Prevent two jan-use trees from sharing a Node isolate / Python worker solely because package env hashes collide or match.
    depends_on: [P1]
    design:
      key_extension: >
        WorkerKey gains spec_dir (canonical path) and optionally root_yaml or
        config identity hash. short_hash includes these fields.
      migration: >
        Old workers without the field naturally miss cache and respawn — OK.
    tasks:
      - id: P3.1
        title: Extend WorkerKey and try_warm_language_exec
        files: [src/runtime_daemon.rs, src/lib.rs]
        steps:
          - Pass ctx.spec_root.spec_dir (canonicalize) into WorkerKey.
        acceptance:
          - Two temp trees with identical inline python get different worker socks
      - id: P3.2
        title: Status line shows truncated spec identity
    exit_criteria:
      - Cross-tree worker separation test passes

  - id: P4
    name: Reduce secret surface on IPC (env handling)
    goal: Avoid shipping the entire environment as JSON when a smaller overlay + FD inheritance can work.
    depends_on: [P1, P3]
    design:
      phase_a_minimal: >
        Send env_mode: overlay | replace. For replace (restricted child env),
        keep explicit map but redact known large non-secret noise if any.
        Cap env map entries and total JSON size with clear errors.
      phase_b_scm_rights: >
        Optional Unix SCM_RIGHTS pass of stdin/stdout/stderr into the job child;
        worker applies env via fork+exec helper without echoing secrets back in
        responses. Responses carry exit_code only when stdio FDs were passed.
      rollback: >
        If SCM_RIGHTS fails, fall back to capture mode (current) then cold spawn.
    tasks:
      - id: P4.1
        title: Env size limits and structured errors
        acceptance:
          - Oversized env fails warm path with JAN_RUNTIME_DEBUG reason; cold may still run
      - id: P4.2
        title: Prefer overlay encoding
        steps:
          - When env does not restrict_child_env, send only PATH/NODE_PATH/CLASSPATH/public/private/pass overlays; worker starts from a scrubbed essential allowlist matching deps::ESSENTIAL_ENV.
        acceptance:
          - Restricted env still exact-match cold spawn behavior (test)
      - id: P4.3
        title: SCM_RIGHTS stdio (Unix)
        files: [src/runtime_daemon.rs, runtime/*]
        steps:
          - Prototype on Python fork path first; then Node if feasible.
        acceptance:
          - Interactive-ish smoke: stdout not base64-roundtripped when FD pass succeeds
          - Secrets never appear in job response JSON
    exit_criteria:
      - P4.1 and P4.2 merged
      - P4.3 merged or explicitly deferred with issue link in this file status

  - id: P5
    name: JAN_RUNTIME_STRICT isolation modes
    goal: Opt-in strong isolation for Node (and optionally all langs) at the cost of speed.
    depends_on: [P1, P2, P3]
    design:
      env: JAN_RUNTIME_STRICT
      values:
        "0": default warm (persistent Node isolate, Python fork)
        "1": Node uses new worker_threads Worker OR child_process per job; clear all require.cache; no shared module state
        "process": force cold Command::new for all language leaves (alias of strong disable)
      yaml_optional_later: exec.runtime_strict — defer unless needed
    tasks:
      - id: P5.1
        title: Wire JAN_RUNTIME_STRICT into try_warm / node worker boot
        steps:
          - Supervisor ensure may pass strict flag in WorkerKey so strict and non-strict do not share a process.
      - id: P5.2
        title: Node strict runner path
        files: [runtime/node_worker.js]
        steps:
          - Per-job Worker (or spawn) with cwd override; discard isolate after job.
        acceptance:
          - Test: job1 mutates cached dep; job2 under strict does not see mutation
          - Benchmark note: document expected regression vs default warm
      - id: P5.3
        title: Docs and help text
        files: [docs/cli/runtime.md, docs/security.md]
    exit_criteria:
      - Strict mode test proves no cross-job module mutation
      - Default warm timings remain within 20% of pre-P5 baseline

  - id: P6
    name: Operational limits and observability
    goal: Make abuse visible and bound resource use.
    depends_on: [P1, P2]
    tasks:
      - id: P6.1
        title: Per-client and global rate limits on ensure/job
        steps:
          - Simple token bucket in supervisor (e.g. 100 ensures/min, 200 jobs/min) — tunable via env.
      - id: P6.2
        title: Status metrics
        steps:
          - Expose auth_fail, timeout_kill, strict_jobs in status line / JSON.
      - id: P6.3
        title: Max concurrent jobs per worker
        steps:
          - Node remains serial by default; document that; optional multi-worker per key later.
    exit_criteria:
      - status shows new counters
      - rate-limit test or manual checklist signed off

  - id: P7
    name: Documentation and MAS wording
    goal: Align security and MAS docs with real isolation guarantees.
    depends_on: [P5, P6]
    tasks:
      - id: P7.1
        title: docs/security.md Runtime pool section (final)
        steps:
          - Same-UID model, token, strict mode, secret IPC caveats, JAN_RUNTIME=0 escape hatch.
      - id: P7.2
        title: docs/cli/runtime.md
        steps:
          - Auth token path, timeouts, strict, worker key identity.
      - id: P7.3
        title: MAS / unifier-jan docs
        steps:
          - Clarify fresh process vs warm pool; Node soft isolation vs Python fork.
      - id: P7.4
        title: Mark this process complete
        steps:
          - Set meta.status to done; record deferred items under backlog.
    exit_criteria:
      - All three doc surfaces updated
      - meta.status == done or backlog explicitly lists deferrals

# ---------------------------------------------------------------------------
backlog:
  - id: B1
    title: Landlock/seccomp optional jail for workers
    reason: Out of non_goals for this process; track if threat model expands.
  - id: B2
    title: exec.timeout YAML field
    reason: Nice UX; not required for P2 if jan supplies default timeout_ms.
  - id: B3
    title: Cron path that skips full jan process relaunch
    reason: Performance, not security; separate project.
  - id: B4
    title: mTLS or abstract-namespace sockets
    reason: Overkill while same-UID is the boundary; token is enough for P1.

# ---------------------------------------------------------------------------
test_plan:
  unit:
    - WorkerKey hash changes when spec_dir changes
    - Auth reject helper
  integration:
    - id: T_auth
      steps:
        - Start runtime; overwrite token; warm job fails closed; restore token; job works
    - id: T_timeout
      steps:
        - Leaf sleeps 5s with timeout_ms=500; expect failure and subsequent leaf success
    - id: T_spec_key
      steps:
        - Two JAN_CONFIG_DIR trees; mutate Node dep cache in tree A; tree B unaffected under default keying
    - id: T_strict
      steps:
        - JAN_RUNTIME_STRICT=1; prove no cross-job module mutation
    - id: T_cold_escape
      steps:
        - JAN_RUNTIME=0 unchanged behavior vs main
  manual:
    - Confirm socket and token modes are 0600
    - Confirm stop removes token and socks

rollout:
  order: [P0, P1, P2, P3, P4, P5, P6, P7]
  feature_flags:
    - name: JAN_RUNTIME
      meaning: "0/false/off disables pool (existing)"
    - name: JAN_RUNTIME_STRICT
      meaning: "added in P5"
    - name: JAN_RUNTIME_DEBUG
      meaning: "existing; must print auth/timeout/fallback reasons"
  release_notes_template: |
    Runtime pool security: socket auth token, job timeouts, per-tree worker
    keys, stricter env IPC, optional JAN_RUNTIME_STRICT for Node isolation.

checklist_before_merge_each_phase:
  - cargo test --test cli_smoke runtime_help_and_warm_python_node
  - cargo test --lib runtime_daemon
  - New phase tests pass
  - docs touched if user-visible
  - No secrets in job response fixtures