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
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