diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/.dependency-cruiser-known-violations.json @@ -0,0 +1 @@ +[] diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 00000000..dbba7aa0 --- /dev/null +++ b/.dependency-cruiser.cjs @@ -0,0 +1,145 @@ +// Enforces the agent-analyzability import graph: the layer DAG and no-cycles +// invariant from docs/agent-analyzability.md. `npm run arch:check` runs this. +// Layer paths (downward-only imports): +// 4 CLI src/cli/**, src/cli.ts +// 3 Runtime src/runtime/** (may reuse compile only through the +// single public entry src/transpiler.ts) +// 2 Compile src/transpile/**, src/transpiler.ts +// 1 Parse/fmt src/parse/**, src/parser.ts, src/format/** +// 0 Shared src/types.ts, src/errors.ts, src/diagnostics.ts, src/version.ts, +// src/env-reserved.ts, src/inline-script-name.ts +// The table in docs/agent-analyzability.md is authoritative; keep these in sync. + +const LAYER0 = + "^src/(types|errors|diagnostics|version|env-reserved|inline-script-name)\\.ts$"; +const LAYER1 = "^src/(parse/|parser\\.ts$|format/)"; +const LAYER2 = "^src/(transpile/|transpiler\\.ts$)"; +const LAYER3 = "^src/runtime/"; +const LAYER4 = "^src/(cli/|cli\\.ts$)"; + +// CLI slice isolation: these vertical slices must not import each other's +// private files. Cross-slice reuse goes through src/cli/shared/** or lower-layer +// public entries. See docs/agent-analyzability.md "CLI slice isolation". +// `commands` is the composition root: it wires the other slices together, so it +// is allowed to import them. CLI_PEER_SLICE (commands excluded) is the set of +// slices that must NOT import each other — peer coupling is the real +// analyzability problem the rule targets. +const CLI_SLICE = "^src/cli/(commands|run|serve|mcp|exec|telemetry)/"; +const CLI_PEER_SLICE = "^src/cli/(run|serve|mcp|exec|telemetry)/"; + +module.exports = { + forbidden: [ + { + name: "no-circular", + comment: + "Cycles break the 'direct deps' interfaces suffice' analyzability story: each side needs the other's body.", + severity: "error", + from: {}, + to: { circular: true }, + }, + { + name: "layer0-shared-leaf-no-upward", + comment: + "Shared leaf (layer 0) may import only other layer-0 files, never parse/format/transpile/runtime/cli.", + severity: "error", + from: { path: LAYER0 }, + to: { path: `${LAYER1}|${LAYER2}|${LAYER3}|${LAYER4}` }, + }, + { + name: "layer1-parse-format-no-upward", + comment: + "Parse/format (layer 1) may import only layer 0, never transpile/runtime/cli.", + severity: "error", + from: { path: LAYER1 }, + to: { path: `${LAYER2}|${LAYER3}|${LAYER4}` }, + }, + { + name: "layer2-transpile-no-upward", + comment: + "Compile (layer 2) must not import runtime or cli (generalizes no-runtime-imports.test.ts).", + severity: "error", + from: { path: LAYER2 }, + to: { path: `${LAYER3}|${LAYER4}` }, + }, + { + name: "layer3-runtime-no-cli", + comment: "Runtime (layer 3) must not import cli (layer 4).", + severity: "error", + from: { path: LAYER3 }, + to: { path: LAYER4 }, + }, + { + name: "layer3-runtime-only-transpile-public-graph", + comment: + "Runtime may reuse the transpile package only through its single public entry (src/transpiler.ts, which re-exports the module-graph API), never src/transpile/** internals (validators, emit, module-graph.ts, etc.).", + severity: "error", + from: { path: LAYER3 }, + to: { path: "^src/transpile/" }, + }, + { + name: "no-deep-imports-into-parse", + comment: + "Parse is a deep module: code OUTSIDE the parse package imports only its public entry (src/parser.ts), never src/parse/** internals. Add a named re-export to src/parser.ts instead of reaching in.", + severity: "error", + from: { pathNot: "^src/(parse/|parser\\.ts$)" }, + to: { path: "^src/parse/" }, + }, + { + name: "no-deep-imports-into-transpile", + comment: + "Transpile is a deep module: code OUTSIDE the transpile package imports only its single public entry (src/transpiler.ts, which re-exports the module-graph API), never module-graph/validator/emit/build internals. Add a named re-export to src/transpiler.ts instead of reaching in.", + severity: "error", + from: { pathNot: "^src/(transpile/|transpiler\\.ts$)" }, + to: { path: "^src/transpile/" }, + }, + { + name: "no-deep-imports-into-runtime", + comment: + "Runtime is a deep module: code OUTSIDE the runtime package imports only its public entries — src/runtime/index.ts (production surface) or src/runtime/testing.ts (named test seams for cross-package *.test.ts) — never src/runtime/** internals (docker, docker-inplace, embedded-assets, kernel/*). Add a named re-export to src/runtime/index.ts (production) or src/runtime/testing.ts (test-only seams) instead of reaching in.", + severity: "error", + from: { pathNot: "^src/runtime/" }, + to: { path: "^src/runtime/", pathNot: "^src/runtime/(index|testing)\\.ts$" }, + }, + { + name: "no-deep-imports-into-format", + comment: + "Format is a deep module: code OUTSIDE the format package imports only its public entry (src/format/index.ts), never src/format/** internals (emit.ts). Add a named re-export to src/format/index.ts instead of reaching in.", + severity: "error", + from: { pathNot: "^src/format/" }, + to: { path: "^src/format/", pathNot: "^src/format/index\\.ts$" }, + }, + { + name: "no-cross-cli-slice-imports", + comment: + "Peer CLI slices (run/serve/mcp/exec/telemetry) are vertical features that must not import each other's private files. Cross-slice reuse goes through src/cli/shared/** or a lower-layer public entry. `commands` is the composition root and is deliberately absent from `from` (CLI_PEER_SLICE): it may import any slice to wire them together. The $1 backreference lets same-slice imports through: to.pathNot excludes the slice captured in from.path.", + severity: "error", + from: { path: CLI_PEER_SLICE }, + to: { path: CLI_SLICE, pathNot: "^src/cli/$1/" }, + }, + { + name: "no-orphans", + comment: + "Orphan modules (no incoming or outgoing deps) are usually dead code or a missing wiring.", + severity: "warn", + from: { + orphan: true, + pathNot: [ + "\\.d\\.ts$", + "(^|/)tsconfig\\.json$", + "\\.test\\.ts$", + "\\.acceptance\\.test\\.ts$", + ], + }, + to: {}, + }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsConfig: { fileName: "tsconfig.json" }, + tsPreCompilationDeps: true, + enhancedResolveOptions: { + exportsFields: ["exports"], + conditionNames: ["import", "require", "node", "default", "types"], + }, + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35bad025..6aefbc2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,12 @@ jobs: - name: Install dependencies run: npm ci + - name: Enforce agent-analyzability import graph (layer DAG, no cycles) + run: npm run arch:check + + - name: Enforce agent-analyzability caps (max-dependencies, max-lines) + run: npm run lint + - name: Run core test suite (unit and acceptance) run: npm test @@ -268,6 +274,11 @@ jobs: set -euo pipefail export DEBIAN_FRONTEND=noninteractive export JAIPH_UNSAFE=true + # wsl.exe does not forward the parent runner's env into this Linux + # session, so CI (which docs/install treats as "trusted toolchain, + # checksum-only OK when minisign is absent") must be re-set here — + # this WSL shell IS the CI runner, just one hop removed. + export CI=true SUDO= if [ "$(id -u)" -ne 0 ]; then SUDO=sudo diff --git a/.github/workflows/nightly-engineer.yml b/.github/workflows/nightly-engineer.yml index 44387215..96e1f990 100644 --- a/.github/workflows/nightly-engineer.yml +++ b/.github/workflows/nightly-engineer.yml @@ -2,18 +2,6 @@ name: Nightly Engineer Run on: workflow_dispatch: - inputs: - engineer_type: - description: "Engineer role (auto uses task classification)" - required: false - default: "auto" - type: choice - options: - - auto - - surgical - - reductionist - - optimizer - - stabilizer permissions: contents: write @@ -83,13 +71,9 @@ jobs: shell: bash run: | set -euo pipefail - - engineer_type="${{ inputs.engineer_type }}" - if [ "${engineer_type}" = "auto" ]; then - ./.jaiph/engineer.jh - else - ./.jaiph/engineer.jh -- "${engineer_type}" - fi + # Queue-driven entry: picks first #dev-ready QUEUE.md task and + # auto-classifies the engineer role (no role CLI arg). + ./.jaiph/engineer.jh - name: Create worktree patch artifact if: always() @@ -190,6 +174,6 @@ jobs: Automated engineer run from workflow dispatch. - Base branch: `nightly` - - Engineer type: `${{ inputs.engineer_type }}` + - Entry: queue-driven `.jaiph/engineer.jh` (auto-classified role) EOF )" --head "${branch_name}" --base nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcbd1382..87d714a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,10 +202,11 @@ jobs: set -euo pipefail rm -f SHA256SUMS.minisig if [ -z "${MINISIGN_SECRET_KEY}" ]; then - echo "MINISIGN_SECRET_KEY secret is not set — skipping detached signature." >&2 - echo "Set the secret to enable signed releases (see docs/contributing.md)." >&2 - echo "Installers fail closed without SHA256SUMS.minisig; do not upload an empty stub" >&2 - echo "(GitHub rejects 0-byte release assets with HTTP 400 Bad Content-Length)." >&2 + echo "MINISIGN_SECRET_KEY secret is not set — refusing to publish an unsigned release." >&2 + echo "Installers fail closed without SHA256SUMS.minisig (finding M-5): publishing" >&2 + echo "unsigned would strip the only out-of-band integrity check for every consumer." >&2 + echo "Set the MINISIGN_SECRET_KEY secret to enable signed releases (docs/contributing.md)." >&2 + exit 1 else command -v minisign >/dev/null key_file="$(mktemp)" diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh index fabc3c07..9a45b823 100755 --- a/.jaiph/engineer.jh +++ b/.jaiph/engineer.jh @@ -1,8 +1,14 @@ #!/usr/bin/env jaiph # -# Picks the first pending task from QUEUE.md, implements it, verifies CI, -# updates docs, removes from queue, and publishes a workspace patch artifact. +# Implement a task: code, CI, docs, commit patch. +# +# CLI / overnight (queue-driven): +# jaiph run .jaiph/engineer.jh +# → default → implement_from_queue (first #dev-ready QUEUE.md task). +# +# Hub / serve / mcp (task parameter, no QUEUE.md): +# export workflow implement_from_task(task) — used by .jaiph/main.jh engineer(task) # import "jaiphlang/artifacts" as artifacts import "jaiphlang/claude" as claude @@ -33,15 +39,20 @@ const safety_constraints = """ Nested sessions share runtime resources and can crash active sessions. - Do not attempt to bypass nested-session guards (for example by unsetting environment variables such as CLAUDECODE). + - Do not modify QUEUE.md in any way (no deleting the current task, no + rewriting headers/bodies, no reordering). QUEUE.md process rule 4 is + for orchestration only: after a successful implement, the workflow calls + queue.remove_completed_task. If you believe a task is done, finish + acceptance work and stop — do not edit the queue file yourself. - Any violation of these constraints is an immediate task failure; stop and report. """ const definition_of_done = """ - Definition of done (QUEUE.md rule 7, verbatim): - "Acceptance criteria are non-negotiable. A task is not done until every + Definition of done: + Acceptance criteria are non-negotiable. A task is not done until every acceptance bullet is verified by a test that fails when the contract is - violated. 'It works on my machine' or 'the existing tests pass' is not - acceptance." + violated. "It works on my machine" or "the existing tests pass" is not + acceptance. """ const code_philosophy = """ @@ -71,6 +82,17 @@ const code_philosophy = """ logs, or platform-dependent text) and add an inline comment explaining why. 9. Source code and docs/architecture.md are the single source of truth. Don't trust documentation blindly. + 10. Agent analyzability (import graph). Before changing any src/ import + structure, read docs/agent-analyzability.md. Import a package only through + its public entry point (e.g. src/parser.ts, src/format/index.ts, + src/transpiler.ts, src/runtime/index.ts, src/cli/index.ts) — never reach + into another package's private files. Respect the layer DAG: imports point + only downward (CLI → Runtime → Compile → Parse/format → Shared leaf); lower + layers never import higher ones. Keep files ≤ ~400 lines and low fan-out + (≤ 8 runtime imports per file); prefer splitting into sibling files over + raising a cap. When package.json defines them, run npm run arch:check and + npm run lint in addition to npm run build and npm test; fix any new + violations before continuing. """ const output_criteria = """ @@ -207,7 +229,6 @@ workflow classify_role(task) { config { agent.model = "sonnet" } - const result = prompt """ ${classification_prompt} @@ -236,7 +257,6 @@ workflow implement(task, role_name) { config { agent.model = "opus" } - run task_text_has_header(task) catch (err) { fail "Provided task does not contain a '## [text]' header" } @@ -262,8 +282,9 @@ workflow implement(task, role_name) { - Following the codebase's existing style and conventions precisely. - Following the code philosophy above for all new and modified code. - Adding or updating tests as needed for acceptance criteria. - - Running npm run build, npm test, and npm run test:e2e; fix any failures - before continuing. + - Running npm run build, npm test, and npm run test:e2e; and, when + package.json defines them, npm run arch:check and npm run lint; fix any + failures before continuing. - Ensuring all acceptance criteria in the task are met. ${definition_of_done} @@ -300,27 +321,58 @@ workflow implement(task, role_name) { """ } -workflow default(name) { - # ensure git.is_clean +# Shared post-implement path: CI, docs parity from the task text, commit, artifact. +# Callers that touch QUEUE.md must do so before this (so the commit includes it). +workflow verify_docs_and_commit(task) { + run ci.ensure_ci_passes() + run docs.update_from_task(task) + const patch_file = run git.commit(task) + run artifacts.save(patch_file) + return patch_file +} + +# Task-parameter entry for serve/mcp hub. Does not read or write QUEUE.md. +export workflow implement_from_task(task) { + run common.arg_nonempty(task) catch (err) { + fail "engineer.implement_from_task requires a non-empty task parameter (markdown with a ## header)" + } + run claude.ensure_usage() - const task = run queue.get_first_task() + const task_header = run first_line_task(task) + log "Implementing task: ${task_header}" + + const role_name = run classify_role(task) + log "Role: ${role_name}" + + run implement(task, role_name) + + const patch_file = run verify_docs_and_commit(task) + log "Patch file: ${patch_file}" + return patch_file +} + +# Queue-driven entry for CLI / overnight loops. Always auto-classifies the role. +export workflow implement_from_queue() { + run claude.ensure_usage() + + const task = run queue.get_first_task() ensure queue.task_is_dev_ready(task) + const task_header = run first_line_task(task) log "Implementing task: ${task_header}" - const role_name = match name { - "" => run classify_role(task) - _ => name - } + const role_name = run classify_role(task) log "Role: ${role_name}" run implement(task, role_name) - run ci.ensure_ci_passes() - run docs.update_from_task(task) run queue.remove_completed_task(task_header) - const patch_file = run git.commit(task) - run artifacts.save(patch_file) + const patch_file = run verify_docs_and_commit(task) + log "Patch file: ${patch_file}" return patch_file } + +workflow default() { + return run implement_from_queue() +} diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh index 37c956d8..1518c82d 100755 --- a/.jaiph/ensure_ci_passes.jh +++ b/.jaiph/ensure_ci_passes.jh @@ -11,7 +11,25 @@ script npm_run_test_ci = ``` while IFS= read -r _v; do unset "$_v" 2>/dev/null || true done < <(compgen -e | grep '^JAIPH_' || true) -exec npm run test:ci +# Full Docker e2e (incl. kind) is required here — same as GitHub Actions. +# Do NOT re-add JAIPH_E2E_SKIP_DOCKER to dodge a flake; fix the harness +# (named waits, EXIT cleanup, probe flake classification, kind heartbeats). +# Heartbeat so JAIPH_STEP_IDLE_KILL_SEC cannot kill a long-but-live test:ci +# when individual e2e scripts go quiet (kind pulls, large docker builds). +( + elapsed=0 + while true; do + sleep 30 + elapsed=$((elapsed + 30)) + printf 'ensure_ci_passes: test:ci still running (%ds elapsed)\n' "${elapsed}" + done +) & +hb_pid=$! +trap 'kill "${hb_pid}" >/dev/null 2>&1 || true' EXIT +npm run test:ci +rc=$? +kill "${hb_pid}" >/dev/null 2>&1 || true +exit "${rc}" ``` script assert_nonempty_file_or_fail = ``` @@ -48,6 +66,12 @@ workflow ensure_ci_passes() { expectations, or removal of obsolete features. - Any test change must be minimal with a clear rationale. - Do NOT add speculative fixes. Fix only what the log shows is broken. + - Do NOT lengthen src/runtime/docker.ts probe timeouts to paper over + Docker Desktop load flakes; fix the e2e harness instead (named + jaiph-run container waits, EXIT cleanup of leftover containers, + surface stderr on failure, probe flake retries / E_DOCKER_PROBE_FAILED). + - Do NOT set JAIPH_E2E_SKIP_DOCKER here — overnight runs the full + suite like GitHub Actions. """ } diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh index e71e65e7..e767d9a8 100755 --- a/.jaiph/libs/jaiphlang/git.jh +++ b/.jaiph/libs/jaiphlang/git.jh @@ -89,6 +89,20 @@ workflow commit(task) { return patch_file_name } +# Like commit(), but no-ops when the worktree is clean (overnight loops). +# Deletes the generated .patch from the worktree so the next loop starts clean. +workflow commit_if_changes(task) { + ensure has_changes() catch (err) { + log "No changes to commit." + return "" + } + const patch_file = run commit(task) + run git_rm_patch(patch_file) + return patch_file +} + +script git_rm_patch = `rm -f -- "$1"` + workflow push(branch) { ensure in_git_repo() run git_push_head(branch) diff --git a/.jaiph/libs/jaiphlang/queue.jh b/.jaiph/libs/jaiphlang/queue.jh old mode 100644 new mode 100755 index f6a83283..33c5b0ac --- a/.jaiph/libs/jaiphlang/queue.jh +++ b/.jaiph/libs/jaiphlang/queue.jh @@ -9,6 +9,7 @@ # jaiph .jaiph/libs/jaiphlang/queue.jh headers # jaiph .jaiph/libs/jaiphlang/queue.jh get dev-ready # jaiph .jaiph/libs/jaiphlang/queue.jh json +# jaiph .jaiph/libs/jaiphlang/queue.jh add_from_file path/to/tasks.md # import script "./queue.py" as queue @@ -18,6 +19,12 @@ workflow default(cmd, arg1, arg2) { log result } +# Append ## tasks from a markdown file into QUEUE.md. Titles already present +# are skipped. Missing #dev-ready tags are added automatically. +export workflow add_tasks_from_file(path) { + run queue("add_from_file", path) +} + # Returns the full text block (header + body) of the first task. export workflow get_first_task() { return run queue("get") diff --git a/.jaiph/libs/jaiphlang/queue.py b/.jaiph/libs/jaiphlang/queue.py index f84029ad..17ccb8bf 100755 --- a/.jaiph/libs/jaiphlang/queue.py +++ b/.jaiph/libs/jaiphlang/queue.py @@ -178,13 +178,54 @@ def cmd_has_tag(args): def cmd_json(args): print(json.dumps(parse_queue(queue_path()), indent=2)) +def cmd_add_from_file(args): + """Append tasks from a markdown file. Skips titles that already exist. + + The file is parsed like QUEUE.md (## Title #tags + body). Existing titles + in QUEUE.md are left untouched. Returns how many tasks were added. + """ + if not args: + print("add_from_file: path required", file=sys.stderr) + sys.exit(1) + src = args[0] + if not os.path.isfile(src): + print(f"add_from_file: file not found: {src}", file=sys.stderr) + sys.exit(1) + incoming = parse_queue(src) + if not incoming["tasks"]: + print("Added 0 tasks (file had no ## sections)") + return + path = queue_path() + q = parse_queue(path) + existing = {t["title"] for t in q["tasks"]} + added = 0 + skipped = 0 + for t in incoming["tasks"]: + if t["title"] in existing: + skipped += 1 + continue + # Overnight / engineer loops require #dev-ready on the header. + tags = list(t["tags"]) + if "dev-ready" not in tags: + tags.append("dev-ready") + q["tasks"].append({ + "title": t["title"], + "tags": tags, + "description": t["description"], + }) + existing.add(t["title"]) + added += 1 + if added: + write_queue(path, q) + print(f"Added {added} tasks" + (f" (skipped {skipped} existing)" if skipped else "")) + cmds = { "get": cmd_get, "get_by_header": cmd_get_by_header, "headers": cmd_headers, "complete": cmd_complete, "complete_by_header": cmd_complete_by_header, "mark": cmd_mark, "set_description": cmd_set_description, "has_tag": cmd_has_tag, "check_all_tagged": cmd_check_all_tagged, - "json": cmd_json, + "json": cmd_json, "add_from_file": cmd_add_from_file, } argv = [a for a in sys.argv[1:] if a] diff --git a/.jaiph/main.jh b/.jaiph/main.jh old mode 100644 new mode 100755 index 42f3d38d..72d0dcca --- a/.jaiph/main.jh +++ b/.jaiph/main.jh @@ -41,10 +41,11 @@ export workflow docs_parity() { run docs_mod.default() } -# Implement the first #dev-ready QUEUE.md task end-to-end: code, CI, docs, commit patch. -# Optional role name (e.g. "engineer"); "" lets the workflow classify the role. -export workflow engineer(role) { - return run eng_mod.default(role) +# Implement a task end-to-end: code, CI, docs, commit patch. Pass the full task +# markdown (must start with a ## header). Does not read or write QUEUE.md — +# queue-driven overnight runs use `.jaiph/engineer.jh` directly instead. +export workflow engineer(task) { + return run eng_mod.implement_from_task(task) } # Run npm run test:ci and loop with an agent until it passes (or recover_limit). @@ -58,13 +59,14 @@ export workflow gh_ci_passes(branch, workflow_name) { run gh_ci_mod.default(branch, workflow_name) } -# OWASP ASI Top 10 security review; writes a report under .jaiph/tmp/ and fails on HIGH. +# OWASP ASI Top 10 security review; report under .jaiph/tmp/, HIGH/MEDIUM → +# #dev-ready QUEUE.md tasks (committed when the queue changes). Overnight-safe. # scope: ""|"codebase"|"full" for whole tree, "diff" for uncommitted, or a git range. export workflow security_review(scope) { run sec_mod.default(scope) } -# Find and apply safe simplifications (no test/e2e edits), then re-run local CI. +# Find and apply safe simplifications (no test/e2e edits), CI, commit if changed. export workflow simplifier() { run simp_mod.default() } @@ -75,7 +77,7 @@ export workflow prepare_release(version) { return run rel_mod.default(version) } -# Find test-coverage gaps and write missing tests until local CI is green. +# Find test-coverage gaps, write missing tests, CI, commit if changed. export workflow qa() { run qa_mod.default() } diff --git a/.jaiph/qa.jh b/.jaiph/qa.jh index 409d1510..80f4079a 100755 --- a/.jaiph/qa.jh +++ b/.jaiph/qa.jh @@ -1,5 +1,11 @@ #!/usr/bin/env jaiph +# +# Find test-coverage gaps, write missing tests, verify CI, commit if changed. +# Safe for overnight loops: starts clean, ends clean (commit or no-op). +# +# jaiph run .jaiph/qa.jh +# import "./ensure_ci_passes.jh" as ci import "jaiphlang/git" as git @@ -229,10 +235,16 @@ workflow write_tests() { script mkdir_tmp_jaiph_qa = `mkdir -p .jaiph/tmp` +const commit_task = """ + QA pass: add missing tests from the gap report under + .jaiph/tmp/qa_gap_report_*.md. Production code unchanged. +""" + workflow default() { - # ensure git.is_clean() + ensure git.is_clean() run mkdir_tmp_jaiph_qa() run analyze_gaps() run write_tests() run ci.ensure_ci_passes() + run git.commit_if_changes(commit_task) } diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh index e97f8ee4..fa0472a1 100755 --- a/.jaiph/security_review.jh +++ b/.jaiph/security_review.jh @@ -11,19 +11,22 @@ # Writes a Diátaxis-style markdown report to # .jaiph/tmp/security_review_.md # (name "security_review" is in the filename) and publishes it as a run -# artifact. Fails when any HIGH severity finding is confirmed. +# artifact. HIGH and MEDIUM findings become #dev-ready QUEUE.md tasks +# (committed when the queue changes) so overnight loops can feed engineer. +# Does not fail the run on HIGH — findings are queued instead. # # Review methodology: OWASP Agentic Security Initiative (ASI) Top 10 via # .jaiph/skills/agent-owasp-compliance/SKILL.md -# Report writing follows .jaiph/kills/documentation-writer/SKILL.md. +# Report writing follows .jaiph/skills/documentation-writer/SKILL.md. # import "./lib_common.jh" as common import "jaiphlang/artifacts" as artifacts import "jaiphlang/git" as git +import "jaiphlang/queue" as queue config { agent.backend = "claude" - agent.model = "opus" + agent.model = "fable" agent.claude_flags = "--permission-mode bypassPermissions" } @@ -31,6 +34,8 @@ script new_security_review_report_path = `echo ".jaiph/tmp/security_review_$(dat script write_security_review_pointer = `printf '%s\n' "$1" > .jaiph/tmp/security_review_active.txt` +script security_review_tasks_path = `echo ".jaiph/tmp/security_review_queue_tasks.md"` + const reviewer_role = """ You are a senior security engineer reviewing Jaiph — a workflow DSL, TypeScript CLI/runtime, Docker sandbox, and agent-backend runner that @@ -170,7 +175,7 @@ workflow review_diff_text(mode, scope_label, diff_text, report_file) { return run review_scope(mode, scope_detail, report_file) } -workflow finish_review(verdict, report_file, fingerprint_before) { +workflow finish_report(verdict, report_file, fingerprint_before) { if verdict == "skip" { log "Security review skipped (nothing in scope)." return "" @@ -186,16 +191,67 @@ workflow finish_review(verdict, report_file, fingerprint_before) { fail "Security review did not write a report at ${report_file}." } run artifacts.save(report_file) + log "Security review report ready: ${report_file}" +} - run common.str_equals(verdict, "pass") catch (err) { - fail """ - Security review found HIGH severity issues. - See ${report_file} (also published to the run artifacts directory). - """ - } - log "Security review passed. Report: ${report_file}" +script truncate_file = `: > "$1"` + +workflow queue_findings(report_file) { + const tasks_file = run security_review_tasks_path() + # Default to empty so a no-finding pass is a clean no-op for add_from_file. + run truncate_file(tasks_file) + + prompt """ + + You turn confirmed security findings into standalone QUEUE.md tasks for + the Jaiph engineer overnight loop. + + + + Read the security review report at ${report_file} and the current + QUEUE.md. + + For every HIGH and MEDIUM finding in the report, write one QUEUE task + into ${tasks_file} (overwrite that file). Skip LOW findings. + + File format — one or more task sections, nothing else. + Only task titles use ##. Body subsections use ### (never ##): + + ## Short imperative title #dev-ready + + Context: <1-2 sentences, ASI id, severity, confidence> + + Problem: + + Location: + + Remediation: + + ### Acceptance criteria + - + - + + Rules: + - Every ## header MUST end with #dev-ready. + - Do not put ## inside a task body (QUEUE.md treats every ## as a new task). + - Each task must be standalone (QUEUE.md rule 5) — no "see prior task". + - Prefer small, implementable tasks; split a broad finding if needed. + - Skip a finding if QUEUE.md already has an equivalent title/topic. + - If there are no HIGH/MEDIUM findings to queue, write an empty file + (zero bytes or whitespace only) — do not invent work. + - Do NOT edit QUEUE.md yourself; only write ${tasks_file}. + - Do not modify any other repository file. + + """ + + run queue.add_tasks_from_file(tasks_file) } +const commit_task = """ + Security review: add #dev-ready QUEUE.md tasks for HIGH/MEDIUM findings + from the latest .jaiph/tmp/security_review_*.md report. +""" + workflow dispatch_review(mode, scope, report_file) { if mode == "codebase" { return run review_codebase(report_file) @@ -210,10 +266,6 @@ workflow dispatch_review(mode, scope, report_file) { workflow default(scope) { ensure git.in_git_repo() - run common.mkdir_p_simple(".jaiph/tmp") - const report_file = run new_security_review_report_path() - run write_security_review_pointer(report_file) - const fingerprint_before = run worktree_fingerprint() const mode = match scope { "" | "codebase" | "full" => "codebase" @@ -221,6 +273,39 @@ workflow default(scope) { _ => "range" } + # Overnight / codebase / range runs require a clean tree so the QUEUE.md + # commit only contains queued findings. Diff mode reviews an existing dirty + # tree and updates QUEUE.md without committing. + if mode != "diff" { + ensure git.branch_clean() + } + + run common.mkdir_p_simple(".jaiph/tmp") + const report_file = run new_security_review_report_path() + run write_security_review_pointer(report_file) + const fingerprint_before = run worktree_fingerprint() + const verdict = run dispatch_review(mode, scope, report_file) - run finish_review(verdict, report_file, fingerprint_before) + run finish_report(verdict, report_file, fingerprint_before) + + if verdict == "skip" { + return "" + } + + run queue_findings(report_file) + + if mode == "diff" { + ensure git.has_changes() catch (err) { + log "Security review finished (no QUEUE.md changes). Report: ${report_file}" + return "" + } + log "QUEUE.md updated; commit skipped in diff mode. Report: ${report_file}" + } else { + run git.commit_if_changes(commit_task) + if verdict == "fail" { + log "Security review found HIGH findings — queued as #dev-ready tasks (see QUEUE.md and ${report_file})." + } else { + log "Security review finished. Report: ${report_file}" + } + } } diff --git a/.jaiph/simplifier.jh b/.jaiph/simplifier.jh old mode 100644 new mode 100755 index ac53fc68..80db3a62 --- a/.jaiph/simplifier.jh +++ b/.jaiph/simplifier.jh @@ -1,5 +1,11 @@ #!/usr/bin/env jaiph +# +# Find and apply safe simplifications, verify CI, commit if anything changed. +# Safe for overnight loops: starts clean, ends clean (commit or no-op). +# +# jaiph run .jaiph/simplifier.jh +# import "./ensure_ci_passes.jh" as ci import "jaiphlang/git" as git @@ -142,6 +148,11 @@ workflow apply_simplifications() { script mkdir_tmp_jaiph = `mkdir -p .jaiph/tmp` +const commit_task = """ + Simplifier pass: apply safe code simplifications from + .jaiph/tmp/simplifier_report.md. Preserve behavior; no test/ or e2e/ edits. +""" + workflow default() { ensure git.is_clean() run mkdir_tmp_jaiph() @@ -149,4 +160,5 @@ workflow default() { run apply_simplifications() run ci.ensure_ci_passes() ensure no_test_or_e2e_paths_changed() + run git.commit_if_changes(commit_task) } diff --git a/AGENT.md b/AGENT.md index 6adbf5c6..26c1212a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -5,7 +5,9 @@ This document defines how coding agents should work in this repository. ## Architecture Source of Truth - `docs/architecture.md` is the source of truth for system architecture and execution flow. +- `docs/agent-analyzability.md` is the source of truth for import-graph layering, deep-module public entries, fan-out/file-size caps, and CI architecture checks. - Before changing parser/transpiler/runtime/CLI boundaries, read `docs/architecture.md` and keep changes aligned. +- Before adding cross-package imports or new `src/` modules, read `docs/agent-analyzability.md` and import only through public entry points, downward through layers. - Preserve the documented contracts: - runtime -> CLI live events via `__JAIPH_EVENT__`, - runtime -> durable artifacts via `.jaiph/runs` and `run_summary.jsonl`, diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd4c901..63b2df67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,94 @@ ## All changes +# 0.13.0 + +## Summary + +- **Shell steps no longer splice untrusted values into `sh -c`:** every value interpolated into a workflow shell step is shell-quoted first, so a workflow parameter, capture, `for` iterator, or channel payload that contains shell metacharacters is passed to the shell as data and cannot inject a command, including when the value is bound through `jaiph mcp` or `jaiph serve`. +- **`jaiph serve` and `jaiph mcp` no longer go host-only from an inherited `JAIPH_UNSAFE=true`:** the long-lived servers now require explicit consent for unsafe host-only execution on their own command line, so `--unsafe` (or `--yes`) must be passed to run every call on the host with no sandbox. An ambient `JAIPH_UNSAFE=true` inherited from the environment, for example a value left in a shell profile by an earlier host-only `jaiph run`, is refused at startup with `E_UNSAFE_NO_CONSENT` instead of silently disabling the sandbox. When consent is given, the server prints a loud multi-line startup banner that states sandboxing is disabled and every call runs on the host with full filesystem and credential access. Inside a container or Kubernetes pod the container is the sandbox, so the refusal is skipped and a factory VPS / standalone runtime-image deploy still runs host-only without `--unsafe` on the command line. +- **The `jaiph serve` operator token no longer crosses into workflow sandboxes:** the environment-forwarding allowlist now excludes the whole host-only `JAIPH_SERVE_*` family, so `JAIPH_SERVE_TOKEN` and the OIDC and server-config keys stay on the host instead of being forwarded into every Docker container and agent subprocess the server runs. +- **The run audit journal is now tamper-resistant and verified when it is read:** each `run_summary.jsonl` line is chained with a keyed HMAC under a per-run secret that never reaches the workflow's own script or agent subprocesses, so a workflow that rewrites, truncates, or deletes its journal can no longer forge a chain that verifies. The per-run key is now stored outside the workflow-writable run directory, in an operator-side store that no sandbox mount reaches, so a workflow can no longer squat the key path or delete its own tamper evidence, and a keyed run whose key later goes missing fails closed instead of silently passing. A completed journal must also end with a `WORKFLOW_END` marker, so deleting the last lines of a finished journal is rejected instead of leaving a shorter chain that still links and verifies. Run listing, the `GET /v1/runs/{id}/events` snapshot, and OTLP and Sentry export now verify the chain and reject a tampered journal instead of trusting it. +- **`jaiph install` and the library registry now verify integrity instead of trusting-on-first-use:** a remotely fetched registry index is signature-verified against a detached `.minisig` (minisign, `jaiph.pub` embedded as the trust anchor) and rejected when missing, unsigned, or tampered; remote registry and library URLs must use `https://`/`ssh://` (a `http://` or other disallowed scheme is refused before any fetch or clone); every shipped registry entry must now pin a `commit` that the cloned HEAD must match on the first install, `npm run registry:build` refuses to write an index with an unpinned entry, and `jaiph install` refuses a registry name whose entry has no pinned commit unless you pass `--allow-unpinned`; and an optional per-library detached signature is verified fail-closed. +- **A workflow file can no longer weaken the Docker sandbox it runs in:** the entry file's `runtime.docker_image` and any isolation-breaking `runtime.docker_network` value (`host`, `container:*`, `ns:*`) are now host-controlled. When Docker is the active sandbox, a file-declared image is rejected (`E_DOCKER_IMAGE_HOST_ONLY`) and a file-declared `host` / `container:*` / `ns:*` network is rejected (`E_DOCKER_NETWORK_HOST_ONLY`), so a repo- or model-supplied workflow can no longer point the sandbox at an arbitrary image or join the host network namespace while still appearing sandboxed. Host-safe in-file network values (`default`, `none`, a named bridge network) are still honoured, and only the operator's `JAIPH_DOCKER_IMAGE` / `JAIPH_DOCKER_NETWORK` can select an image or an isolation-breaking network. +- **The image presence check no longer runs unhardened image code:** the check that confirms a Docker image contains `jaiph` before a run now uses the same sandbox hardening as the run itself (every capability dropped, no new privileges, a non-root user, and no network) and a non-login shell, so it can no longer source or execute startup and profile scripts baked into a workflow-selected image at a higher privilege than the run. +- **The default sandbox image is now pinned by digest and verified on every run:** the official `ghcr.io/jaiphlang/jaiph-runtime` image ships an expected manifest digest with each release, and every Docker-backed `jaiph run` resolves and checks the local image's digest against it, including on a cache hit, so a re-pointed tag or a poisoned local image cache under the same tag can no longer swap the sandbox rootfs while the run still looks sandboxed. A mismatch fails closed with `E_DOCKER_DIGEST_MISMATCH` and a message that tells you how to re-pull the pinned image, and `JAIPH_DOCKER_IMAGE_DIGEST` lets you pin or override the digest for any image. +- **A workflow file can no longer pull arbitrary host secrets into the Docker sandbox by declaring them:** the entry file's `trusted_envs` keys cross the sandbox allowlist only when the operator opts in with `JAIPH_TRUSTED_ENVS=1`. Absent the opt-in, a file-declared `trusted_envs` is ignored under Docker with a pre-flight warning, so an untrusted or model-edited entry naming `AWS_SECRET_ACCESS_KEY` or `GITHUB_TOKEN` cannot forward that host secret across the allowlist on its own. Host modes have no allowlist to bypass, so they honour the declaration as before, and authoring the entry file is now a trust boundary equal to `--env`. +- **A `sub`-less OIDC token no longer collapses onto one shared identity:** the OIDC principal is the token `sub`, falling back to `client_id` for machine tokens (OAuth2 client-credentials) that omit `sub`, and a verified token carrying neither claim is rejected with `401` instead of authenticating as a shared `unknown` principal. Two machine callers on the same issuer can no longer share one run-visibility bucket or idempotency namespace, so neither can list, read, or cancel the other's runs. +- **Project-local `.jaiph/hooks.json` no longer runs on the host without a workspace-trust decision:** hook commands run in the host CLI process, before and outside any Docker sandbox, so a `/.jaiph/hooks.json` that arrives with a cloned or untrusted repository is now gated behind the operator opt-in `JAIPH_TRUST_PROJECT_HOOKS=1`. Absent the opt-in, `jaiph run`, `jaiph serve`, and `jaiph mcp` ignore the project file with a one-line stderr notice, so a cloned repo cannot execute arbitrary host commands on `workflow_start`. The global `~/.jaiph/hooks.json` is the operator's own and always runs. +- **Release install and the runtime image now verify every download instead of failing open:** the binary installer requires a valid minisign signature, so on a normal host a missing `minisign` aborts the install rather than degrading to checksum-only, an empty `JAIPH_MINISIGN_PUBLIC_KEY` fails closed, and only `JAIPH_ALLOW_UNSIGNED=1` proceeds on checksum alone (finding M-5 removed the earlier `CI` opt-out, so CI installs must make `minisign` available). The `jaiph run`, `jaiph init`, and `jaiph use` bootstraps fetch `docs/install` and its published `install.sha256`, verify the two match, and refuse to run a tampered script instead of piping `curl … | bash`. Every toolchain fetch in `runtime/Dockerfile` now goes through `runtime/fetch-verify.sh` with a required, pinned SHA-256, so a poisoned toolchain CDN fails the build. + +- **CI installs and the `setup-jaiph` action now require a verified release signature:** the installer no longer downgrades to a checksum-only install when `CI` is set, so a missing `minisign` aborts the install on every host, and only an explicit `JAIPH_ALLOW_UNSIGNED=1` proceeds on checksum alone with a prominent warning. The `setup-jaiph` GitHub Actions action installs `minisign` on the runner so the action path always verifies the signature, and the release build now fails when the signing key is unset instead of publishing unsigned artifacts. +- **Credential redaction now covers many more secret names and their encoded forms:** the run journal and every surface that reads it back (`GET /v1/runs/{id}/events`, the OTLP export, the Sentry export, and a failed call's returned `result_text`) redact the value of any env var whose name looks like a credential, which now includes names the earlier four-suffix rule missed such as `AWS_SECRET_ACCESS_KEY`, `STRIPE_SECRET_KEY`, `DB_PASSWORD`, `PASSPHRASE`, and `SSH_PRIVATE_KEY`, and each value is redacted in its base64, hex, and URL-encoded forms as well as its raw form. Redaction still works by literal-substring replacement, so a secret transformed some other way, such as split across output chunks or embedded inside an opaque connection string, is not guaranteed to be caught, and the raw per-step capture files stay sensitive. +- **`jaiph serve` now serves a self-contained Swagger UI:** `/docs` embeds the pinned `swagger-ui-dist` assets in the jaiph binary and serves them from same-origin `/docs/*` paths, so the built-in API UI renders and can invoke workflows with no browser internet access, including on an air-gapped network or behind a Content-Security-Policy that blocks third-party hosts. `JAIPH_SERVE_EXPOSE_DOCS=false` still returns `404` for `/docs`, `/openapi.json`, and the embedded assets. +- **OIDC token verification now pins an explicit signing-algorithm allowlist:** `jaiph serve` accepts an OIDC bearer JWT only when its header names one of the pinned asymmetric algorithms — the RSA (`RS*` / `PS*`), ECDSA (`ES256` / `ES384` / `ES512`), and EdDSA families that standard OIDC providers sign with — and rejects symmetric algorithms (`HS*`), `alg: none`, and the non-recommended secp256k1 curve (`ES256K`) even when the signing key is present in the JWKS. Pinning the allowlist means a future key-type or JWKS change can never make an algorithm-confusion or `alg: none` forgery reachable, though `jose` already rejected those cases today. +- **A host run can now be bounded by a wall-clock timeout and a max-step circuit breaker:** `JAIPH_RUN_TIMEOUT` (seconds) gives a host-mode run — a `jaiph run --unsafe` or host-only run, and the host spawn a `jaiph serve` or `jaiph mcp` call uses — a parent-enforced wall-clock cap that terminates the run's whole process group (`SIGTERM`, then `SIGKILL`) once the budget is reached, so it stops without a manual Ctrl-C where before Ctrl-C was the only automatic stop. Docker mode keeps using `JAIPH_DOCKER_TIMEOUT`. `JAIPH_MAX_STEPS` adds an optional circuit breaker in the runtime that counts every executed step across the whole run, including loop iterations and nested or recursive calls, and aborts a runaway workflow once the count exceeds the cap. Both are off by default. +- **A leaf script step that goes silent is now killed after an idle-output timeout:** when a script step's subprocess produces no stdout or stderr for `JAIPH_STEP_IDLE_KILL_SEC` (default 3600 seconds, one hour; `0` disables), the runtime records a `LOGERR` naming the step and how long it was silent, terminates the step's subprocess (SIGTERM, then SIGKILL), and fails the step, so an overnight run can no longer hang for hours on a stuck command that stopped producing output. Any new output resets the timer, the periodic idle warnings on `JAIPH_STEP_IDLE_WARN_SEC` are unchanged and run on their own independent cadence, and prompt steps still get warnings only. +- **Local-source builds now enforce the lockfile with `npm ci` and exact-pin the one runtime dependency:** the from-source installer (`docs/install`) runs `npm ci` when the checkout has a `package-lock.json`, so a clean-room install uses exactly the versions the lockfile pins instead of letting `npm install` re-resolve caret ranges, and it falls back to `npm install` only when no lockfile is present. The single runtime dependency `jose` is now exact-pinned to `5.10.0` in `package.json` (no `^` caret range); the dev dependencies keep their caret ranges. +- **The runtime sandbox image now pins its base images by digest and its global npm installs by exact version:** every `FROM` in `runtime/Dockerfile` references its base image by an `@sha256:` digest instead of a mutable tag, and the global `npm install -g` of pnpm, yarn, and the Claude Code CLI each pins an exact version through a build ARG, so the built image is reproducible and its registry-sourced layers are attested the same way the direct toolchain downloads already are. A CI check rejects any later edit that reintroduces a bare `FROM` tag or an unpinned global install. + +## All changes + +- **Feat — `jaiph mcp` and `jaiph serve` now write an operator log to stderr:** an operator watching either server can now see which workflow started, under which sandbox posture, and where its run directory is, without reading `.jaiph/runs`. On every tool call or run the server writes a start line (`jaiph mcp: Running () run_id=…`) and an end line (`Finished status=ok exit=0 elapsed_ms=… rundir=…`), where the sandbox label uses the same words as the startup banner (snapshot, in-place, unsafe, or no sandbox). `jaiph serve` also carries `principal=` and `correlation=` on both lines, and its earlier duplicate invoke line is removed so the start is logged once. The operator log is stderr only and never touches the protocol channel, so MCP stdout stays JSON-RPC and HTTP response bodies stay API payloads. It is not a logging framework and adds no dependency, with no winston, pino, or bunyan. A small shared helper (`src/cli/shared/server-log.ts`) wraps the existing stderr sink with a label, level colors, and grep-friendly `key=value` tails, and reuses the `jaiph run` color and indent helpers now shared from `src/cli/shared/log-format.ts`. Colors are used only on a terminal sink with `NO_COLOR` unset. Two environment variables tune verbosity. `JAIPH_SERVER_LOG=debug` prints the servers' `debug` diagnostic lines, and `JAIPH_SERVER_LOG_WORKFLOW=1` mirrors each workflow `log`/`logwarn`/`logerr` event to the operator log, colored by level with `run_id=` and the run tree's depth and async-branch indent. Mirroring is off by default so an MCP host is not flooded and the tool-result text is not repeated, and mirrored lines go through the same credential redaction as the durable run journal so a secret is never printed to stderr. Workflow `LOG`/`LOGWARN`/`LOGERR` events keep their existing contract in `run_summary.jsonl` and the call result text. Tests: `src/cli/shared/server-log.test.ts`, `src/cli/shared/server-log-call.test.ts`. Docs: [CLI](docs/cli.md) (the `jaiph mcp` operator-log section and the `jaiph serve` note) and [Environment variables](docs/env-vars.md) (`JAIPH_SERVER_LOG`, `JAIPH_SERVER_LOG_WORKFLOW`). +- **Chore — split the grandfathered hotspot files and drop their ESLint overrides:** the [Agent analyzability](docs/agent-analyzability.md) caps hold each production file under `src/` to 8 runtime imports (`import/max-dependencies`) and 400 non-blank, non-comment lines (`max-lines`), but sixteen of the hottest files were grandfathered with a per-file override in `eslint.config.mjs` that turned off the cap they broke, so the caps did not apply where an agent pays the most context cost. Twelve of the sixteen files were split into sibling modules in the same directory, so each now meets both caps under the global rules and its override was deleted: `src/cli/commands/mcp.ts`, `src/cli/commands/serve.ts`, `src/cli/shared/generation.ts`, `src/cli/index.ts`, `src/cli/shared/workflow-call.ts`, `src/parser.ts`, `src/runtime/index.ts`, `src/transpile/validate.ts`, `src/transpile/validate-step.ts`, `src/format/emit.ts`, `src/runtime/docker.ts`, and `src/runtime/kernel/prompt.ts`. Each split follows the factory `code_philosophy`, which prefers sibling files in the same directory over a deeper tree, and it keeps the package public entry curated with no `export *` barrel. Four files still carry an override, now each with a fresh justification because each needs a larger multi-file decomposition that is out of scope here: `src/cli/commands/run.ts` (the `jaiph run` orchestrator that wires the parse, transpile, and runtime phases plus the run-slice helpers), `src/cli/serve/handler.ts` (the HTTP handler that dispatches every route inline), `src/parse/workflow-brace.ts` (the statement parser, whose handlers are mutually recursive with the structural parsers, so a split risks an import cycle), and `src/runtime/kernel/node-workflow-runtime.ts` (the workflow kernel interpreter, the largest single unit in the tree). The global caps were never raised, `npm run lint` exits 0, and `npm run build` and `npm test` pass with no behavior change. Tests: `src/eslint-caps.test.ts` (lint passes on the committed tree, and the grandfather overrides list exactly the files that still violate a cap, so a split file cannot stay grandfathered and a new violator cannot land unlisted). Docs: the enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md), and the `npm run lint` command row in [Contributing](docs/contributing.md). +- **Chore — collapse the transpile package to a single public entry (`src/transpiler.ts`):** the [Agent analyzability](docs/agent-analyzability.md) contract treats each package as a deep module whose outsiders import only one public entry, but the transpile package had two: `src/transpiler.ts` and the module-graph API `src/transpile/module-graph.ts`, which `.dependency-cruiser.cjs` allowlisted as an exception so runtime could reuse the same graph. Two doors weakened the "one contract per package" model an agent relies on, because a caller could bypass `src/transpiler.ts` and reach `module-graph.ts` directly. `src/transpiler.ts` now re-exports the full module-graph API (`loadModuleGraph`, `readModuleGraph`, `writeModuleGraph`, `moduleGraphFromAsts`, `serializeModuleGraph`, `deserializeModuleGraph`, and the `ModuleGraph` / `ModuleNode` types), so it carries the whole public surface instead of just `loadModuleGraph`. Every outside import of `src/transpile/module-graph.ts`, including the runtime call sites, now goes through `src/transpiler.ts`. The `no-deep-imports-into-transpile` and `layer3-runtime-only-transpile-public-graph` rules in `.dependency-cruiser.cjs` drop their `module-graph.ts` `pathNot` exceptions, so runtime may import only `src/transpiler.ts` from the transpile package and any other `src/transpile/**` path fails the check. No file outside `src/transpile/` imports `src/transpile/module-graph.ts`, and no compile or runtime behavior changed. Tests: `src/arch-check.test.ts` (no production file outside the transpile package imports `src/transpile/module-graph.ts`, a synthetic runtime import of any `src/transpile/**` path other than `src/transpiler.ts` fails the committed config, and the full module-graph API stays importable from `src/transpiler.ts`). Docs: the single-entry transpile row, allowlisted-exception note, and "landed today" note in [Agent analyzability](docs/agent-analyzability.md), the updated transpile **Public entry** note and `no-deep-imports-into-transpile` clause in [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). +- **Chore — expose runtime test seams on a second public entry (`src/runtime/testing.ts`) and clear the deep-runtime baseline:** the `no-deep-imports-into-runtime` rule bans any import of a `src/runtime/**` internal from outside the runtime package, but twelve leftover violations were still grandfathered in `.dependency-cruiser-known-violations.json`, all of them `*.test.ts` files reaching into runtime internals such as `_dockerExec`, `docker-inplace`, `kernel/emit`, `RuntimeEventEmitter`, and the graph and runner internals to stub or inspect them. Production call sites already went through the public entry, so the baseline covered only test imports, but it still let a debugging agent load a private runtime path through a test and it hid any new deep import behind the tracked ones. A second public entry, `src/runtime/testing.ts`, now re-exports the small named set of seams that cross-package tests need: `_dockerExec` and `_dockerSpawn` (the Docker exec and spawn indirection), `_inplacePrompt` (the in-place run prompt), `CHAIN_GENESIS` and `chainHmac` (the audit-chain HMAC internals), and `RuntimeEventEmitter` (the live-event emitter). `.dependency-cruiser.cjs` allowlists `src/runtime/testing.ts` beside `src/runtime/index.ts` in the `no-deep-imports-into-runtime` rule, so these seams stay off the production `index.ts`, every baselined test import was retargeted to the seam entry, and no test reaches a raw `src/runtime/**` path. Two upward test layer edges were cleared by moving the test to its correct layer instead of baselining it: the parser-error snapshot test that needs `loadModuleGraph` moved from `src/parse/` to `src/transpile/` (`parse-error-snapshot.test.ts`), and the compile-to-runtime graph-reuse test that needs `buildRuntimeGraph` moved from `src/transpile/` to `src/runtime/` (`module-graph.test.ts`). The `.dependency-cruiser-known-violations.json` baseline now carries zero `no-deep-imports-into-runtime`, `layer1-parse-format-no-upward`, and `layer2-transpile-no-upward` entries, and none of these rules fires when the committed config runs against `src/` without the baseline (`--no-ignore-known`), so the guard enforces the invariant rather than merely tracking it. No runtime behavior changed. Tests: `src/arch-check.test.ts` (the baseline carries zero entries for those three rules and none surface under `--no-ignore-known`; a seam imported through `src/runtime/testing.ts` passes the committed config while the same import from a raw `src/runtime/**` path still fails). Docs: the updated runtime deep-module row and "landed today" note in [Agent analyzability](docs/agent-analyzability.md), the new **Test-seam entry** note plus the updated runtime **Public entry** note and `no-deep-imports-into-runtime` clause in [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). +- **Chore — enforce CLI slice isolation in dependency-cruiser (`no-cross-cli-slice-imports`):** the [Agent analyzability](docs/agent-analyzability.md) contract treats the CLI slices `commands`, `run`, `serve`, `mcp`, `exec`, and `telemetry` as vertical features that must not import each other's private files, with cross-slice reuse going through `src/cli/shared/**` or a lower-layer public entry, but nothing enforced it, so a file in one slice could reach into another slice's internals and force an agent to load an unrelated slice for a single command change. A new `no-cross-cli-slice-imports` error rule in `.dependency-cruiser.cjs` fails any import from a file under `src/cli//` into another slice's tree, using a `$1` path-group backreference so same-slice imports and imports of `src/cli/shared/**` or the CLI package entry stay allowed. The one back-edge that was a genuinely shared display helper was fixed by moving `src/cli/commands/format-params.ts` into `src/cli/shared/format-params.ts`, and its `src/cli/run/display.ts` and `src/cli/commands/run.ts` call sites now import from `../shared/format-params.js`. The remaining cross-slice edges are composition-root wiring (`commands/*` launching each feature) and feature composition (`serve` exposing `mcp` tools and `exec` over HTTP, and `exec` reusing the `run` lifecycle), which cannot move within this task and are baselined in `.dependency-cruiser-known-violations.json` rather than fixed. No command behavior changed. Tests: `src/arch-check.test.ts` (a synthetic cross-slice import from `commands` into `serve` fails the committed config while same-slice imports and imports of `src/cli/shared/**` pass, and no production CLI file cross-imports another slice's private tree except the committed baseline, with the tolerated count reported). Docs: the updated enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md), the `no-cross-cli-slice-imports` rule in the import-graph section of [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). +- **Chore — add a public format entry (`src/format/index.ts`) and ban deep imports into format:** the [Agent analyzability](docs/agent-analyzability.md) contract puts the format package at layer 1 beside parse and treats it as a deep module whose outsiders import only the public entry, but `src/cli/commands/format.ts` imported `emitModule` from the internal `src/format/emit.ts` directly, which coupled the CLI to a format internal and forced an agent reading the caller to also load it. `src/format/index.ts` is now the sole external entry for the format slice: it re-exports the intentional formatter API (`emitModule` and the `EmitOptions` type) and is not an `export *` barrel of the tree. The one outside call site, `src/cli/commands/format.ts`, now imports from `src/format` instead of `src/format/emit`. A new `no-deep-imports-into-format` error rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/format/**` internal, so `npm run arch:check` now rejects a new format deep import while a caller that goes through `src/format/index.ts` passes. Format keeps importing only parse and types, so no format production source imports `src/cli`, `src/runtime`, or `src/transpile`. Format internals were not otherwise redesigned. Tests: `src/arch-check.test.ts` (a synthetic deep import into format fails the committed config while the same import through `src/format/index.ts` passes; no production file outside the format package deep-imports `src/format/**`; no format source imports an upward layer; `src/format/index.ts` exists and uses no `export *` barrel; and the deep-modules table in `docs/agent-analyzability.md` names `src/format/index.ts` as the format public entry). Docs: the updated format row, enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md), the new **Public entry** note plus the `no-deep-imports-into-format` rule in the import-graph section of [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). +- **Chore — add a public runtime entry (`src/runtime/index.ts`) and stop the runtime importing CLI:** the [Agent analyzability](docs/agent-analyzability.md) contract puts the runtime at layer 3 and the CLI at layer 4, so imports may only point downward, but `src/runtime/kernel/node-workflow-runtime.ts` imported `buildStepDisplayParamPairs` from `src/cli/commands/format-params.ts` — a runtime→CLI edge that inverted the layer DAG and pulled a CLI command module into kernel analysis. That helper moved into the runtime at `src/runtime/kernel/format-params.ts` (the kernel emits these pairs on every managed step); `src/cli/commands/format-params.ts` re-exports it through the new public entry so its CLI callers and tests keep one import site. `src/runtime/index.ts` is now the sole external entry for the runtime slice: it re-exports a curated CLI-facing API (`buildRuntimeGraph` and `RuntimeGraph`; `runWorkflowRunner`, `WORKFLOW_RUNNER_ARG`, `spawnJaiphWorkflowProcess`, `runTestFile`; the Docker sandbox surface `spawnDockerProcess`, `stopDockerContainer`, `stopDockerRunOnSignal`, `withDockerExitGuard`, `resolveDockerConfig`, `resolveDockerHostRunsRoot`, `checkDockerAvailable`, `prepareImage`, `selectSandboxMode`, `selectMcpSandboxMode`, `isRunningInContainer`, `isEnvAllowed`, `CONTAINER_RUN_DIR`, `DOCKER_SANDBOX_ENV`, `RUN_WORKFLOW_ENV`, and the `DockerRunConfig` / `SandboxMode` / `AgentBackend` types; `confirmInplaceRun`, `confirmUnsafeRun`, `UNSAFE_RUN_LOGWARN_MESSAGE`; `CHAIN_KEY_ENV`, `generateChainKey`, `writeChainKey`, `verifyRunJournal`, `redactCredentials`; `canUseAnsi`, `killProcessTree`, `resolveShell`; the embedded-asset accessors; and `buildStepDisplayParamPairs`) and is not an `export *` barrel of the tree. Every production CLI call site that reached a runtime internal (docker, docker-inplace, embedded-assets, `kernel/emit`, `kernel/portability`, `kernel/redact`, `kernel/node-workflow-runner`, `kernel/workflow-launch`, `kernel/node-test-runner`) was retargeted to `src/runtime/index.ts`. A new `no-deep-imports-into-runtime` error rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/runtime/**` internal, so `npm run arch:check` now rejects a new runtime deep import while a caller that goes through `src/runtime/index.ts` passes, and there are zero baselined `runtime`→`cli` edges. Two kinds of leftover are baselined in `.dependency-cruiser-known-violations.json` rather than fixed: `src/config.ts` → `src/runtime/kernel/runtime-arg-parser.ts` (routing it through the entry would form a cycle, because the entry transitively imports `src/config.ts`) and cross-package test-seam imports (`_dockerExec`, `RuntimeEventEmitter`, `CHAIN_GENESIS`, …) that are not part of the public surface. Runtime internals were not otherwise redesigned. Tests: `src/arch-check.test.ts` (a synthetic runtime→CLI import fails the committed config while a runtime import of layer 0 passes; no production file under `src/runtime/` imports `src/cli/**`; a synthetic deep import into runtime fails while the same import through `src/runtime/index.ts` passes; no production file outside the runtime package deep-imports `src/runtime/**` except the baselined paths, with the tolerated count reported; no `runtime`→`cli` edge is merely baselined; `src/runtime/index.ts` uses no `export *` barrel; and the deep-modules table names it as the runtime public entry). Docs: the updated runtime enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md). +- **Chore — make `src/transpiler.ts` the public transpile entry and ban deep imports into transpile:** the [Agent analyzability](docs/agent-analyzability.md) contract treats each package as a deep module whose outsiders import only the public entry, but code outside `src/transpile/` reached into validator internals such as `src/transpile/validate.ts` and `src/transpile/build.ts` directly, which coupled CLI and runtime callers to those internals and forced an agent reading a caller to also load them. `src/transpiler.ts` is now the sole external entry for the transpile slice: it re-exports a curated compile and validate API (`buildScripts`, `buildScriptsFromGraph`, `emitScriptsForModule`, `emitScriptsForModuleFromGraph`, `loadModuleGraph`, `collectDiagnostics`, `validateReferences`, `walkjhFiles`, `walkTestFiles`, `resolveImportPath`, `workflowSymbolForFile`, and the `ModuleGraph` / `ModuleNode` / `ScriptArtifact` types) and is not an `export *` barrel of the tree. At the time of this change one allowed exception remained, `src/transpile/module-graph.ts`, the public module-graph API that runtime reused directly; a later change in this release collapsed that second door into `src/transpiler.ts`, so runtime now reaches the module-graph API through the single entry too (see the collapse entry above). The two outside call sites that reached past the entry were retargeted: `src/cli/commands/compile.ts` (`collectDiagnostics`, `walkjhFiles`) and `src/cli/shared/generation.ts` (`collectDiagnostics`) now import from `src/transpiler.ts`. A new `no-deep-imports-into-transpile` error rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/transpile/**` internal other than `module-graph.ts`, so `npm run arch:check` now rejects a new transpile deep import while a caller that goes through `src/transpiler.ts` or the module-graph API passes. One pre-existing deep import (`src/parse/metadata.ts` → `src/transpile/validate-string.ts`) is baselined in `.dependency-cruiser-known-violations.json` rather than fixed, because routing it through the entry would form a cycle: it is an upward parse-to-transpile edge that is already tracked as a layer violation, so clearing it needs an out-of-scope move. The existing invariant that transpile production sources must not import `src/runtime/` still holds and `src/transpile/no-runtime-imports.test.ts` still passes. Transpile internals were not otherwise redesigned. Tests: `src/arch-check.test.ts` (a synthetic deep import into transpile fails the committed config while the same import through `src/transpiler.ts` passes, the public module-graph API stays importable from outside, and no production file outside the transpile package deep-imports `src/transpile/**` except `module-graph.ts` and the one baselined path, with the tolerated count reported). Docs: the updated transpile enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md), the new **Public entry** note plus the `no-deep-imports-into-transpile` rule in the import-graph section of [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). + +- **Chore — make `src/parser.ts` the public parse entry and ban deep imports into parse:** the [Agent analyzability](docs/agent-analyzability.md) contract treats each package as a deep module whose outsiders import only the public entry, but code outside `src/parse/` reached into internals such as `src/parse/core.ts`, `src/parse/trivia.ts`, `src/parse/triple-quote.ts`, `src/parse/script-bash.ts`, `src/parse/scripts.ts`, and `src/parse/metadata.ts` directly, which forced an agent reading a caller to also load private parser internals. `src/parser.ts` is now the sole external entry for the parse slice: it re-exports a curated public API (the two parse entry points plus named helpers `configValueHasInterpolation`, `parseCallRef`, `matchSendOperator`, `isJaiphInterpolationRef`, `argsToRuntimeString`, `createTrivia`, the `NodeTrivia` / `Trivia` types, `scriptShebangIsBash`, `resolveInterpreterFromShebang`, `langToShebang`, and `canonicalizeTripleQuotedString`) and is not an `export *` barrel of the tree. Every import from outside `src/parse/` was retargeted from a `src/parse/**` file to `src/parser.ts` (`src/config.ts`, `src/format/emit.ts`, `src/runtime/kernel/node-workflow-runtime.ts`, `src/runtime/kernel/runtime-arg-parser.ts`, `src/transpile/emit-script.ts`, `src/transpile/validate-config.ts`, and `src/transpile/validate-step.ts`). A new `no-deep-imports-into-parse` error rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/parse/**` internal, so `npm run arch:check` now rejects a new parse deep import while a caller that goes through `src/parser.ts` passes. One pre-existing deep import (`src/transpile/validate-string.ts` → `src/parse/core.ts`) is baselined in `.dependency-cruiser-known-violations.json` rather than fixed. Routing it through the entry would form a cycle, because `src/parse/metadata.ts` already imports `src/transpile/validate-string.ts` (a separate tracked layer violation), so clearing it needs the out-of-scope removal of that parse-to-transpile edge. Parser internals were not otherwise redesigned. Tests: `src/arch-check.test.ts` (a synthetic deep import into parse fails the committed config while the same import through `src/parser.ts` passes; no production file outside the parse package deep-imports `src/parse/**` except the one baselined path, with the tolerated count reported; `src/parser.ts` uses no `export *` barrel; and the deep-modules table in `docs/agent-analyzability.md` names `src/parser.ts` as the parse public entry). Docs: the updated parse row, enforcement table, "landed today" note, and status in [Agent analyzability](docs/agent-analyzability.md), the new **Public entry** note plus the `no-deep-imports-into-parse` rule in the import-graph section of [Architecture](docs/architecture.md), and the `arch:check` command row in [Contributing](docs/contributing.md). + +- **Security — require explicit unsafe consent for `jaiph serve` / `jaiph mcp` host-only mode (finding M-1):** `resolveStartupPosture` (`src/cli/shared/generation.ts`) builds the runtime env by spreading `process.env`, so an inherited or exported `JAIPH_UNSAFE=true`, for example a value left in a shell profile by an earlier host-only `jaiph run`, silently switched `jaiph mcp ./untrusted.jh` or `jaiph serve` into host-only execution. Every tool call then ran on the host with full filesystem and credential access, unsandboxed, behind a single stderr log line with no prompt and no explicit consent. `jaiph run` already gates host-only execution behind an interactive confirmation, but the servers have no prompt, so the consent is now an explicit `--unsafe` (or `--yes`) on the server's own command line. `resolveStartupPosture` refuses host-only execution requested only by an inherited `JAIPH_UNSAFE=true` with `E_UNSAFE_NO_CONSENT` before any call can run unsandboxed, unless the flag is present or the process runs inside a container (`isRunningInContainer`), where the container itself is the sandbox and the standalone runtime image bakes `JAIPH_UNSAFE=true`. When host-only mode is entered, `logStartupPosture` prints a loud, non-suppressible multi-line startup banner (`formatUnsafeServerBanner`) that states sandboxing is disabled and every call runs on the host with full filesystem and host environment access, replacing the single stderr notice. `jaiph run` behavior is unchanged, and without `JAIPH_UNSAFE` and without the flag the servers keep the sandboxed default. Tests: `src/cli/shared/generation-posture.test.ts` (an inherited `JAIPH_UNSAFE=true` with no flag is refused with `E_UNSAFE_NO_CONSENT`, `--unsafe` and `--yes` resolve host-only and emit the `SANDBOXING DISABLED` banner across more than one line, and an explicit config-off posture is not the unsafe opt-in) and `integration/mcp-server.test.ts` (an ambient `JAIPH_UNSAFE=true` with no flag exits `1` with an empty stdout and `E_UNSAFE_NO_CONSENT` on stderr, while `--unsafe` runs host-only). Docs: the new server-mode `JAIPH_UNSAFE` semantics and the `E_UNSAFE_NO_CONSENT` error row in [Environment variables](docs/env-vars.md), the `--unsafe` flag rows and execution notes for `jaiph mcp` and `jaiph serve` in [CLI](docs/cli.md), and the consent notes in [Serve workflows as MCP tools](docs/mcp.md) and [Serve workflows over HTTP](docs/serve.md). (Security review, ASI-04, MEDIUM, confidence 0.85.) + +- **Security — use `npm ci` and exact-pin the runtime dependency in local builds (finding L-4):** `package.json` declared the runtime dependency as `jose: ^5.10.0`, and the local-source build path (`docs/install` under `JAIPH_FROM_LOCAL`, driven by `docs/install-from-local.sh`) ran a bare `npm install`, so a from-source build could re-resolve the caret range and drift from the committed `package-lock.json` even though the lockfile already pinned every transitive version. `docs/install` now checks for `${tmp_dir}/src/package-lock.json` and runs `npm ci` when the lockfile is present — `npm ci` installs exactly the versions the lockfile pins and fails when `package.json` and the lockfile disagree — falling back to `npm install` only when no lockfile is there. `jose` is exact-pinned to `5.10.0` in `package.json` and `package-lock.json` with no `^` range, while the dev dependencies keep their caret ranges. Tests: `e2e/tests/06_bootstrap_integrity.sh` (the install script guards on a present `package-lock.json` and runs `npm ci` in that branch, and `jose` in `package.json` carries no range operator and equals `5.10.0`). Docs: the `npm ci` command-choice note on the install-from-source line of [Contributing](docs/contributing.md#installing-from-source). (Security review, ASI-09, LOW, confidence 0.72.) + +- **Security — pin the runtime Dockerfile base images and global npm installs (finding L-4):** `runtime/Dockerfile` referenced its base images `node:22-bookworm-slim` (builder stage) and `ubuntu:24.04` (runtime stage) by mutable tag, and ran `npm install -g pnpm yarn` and `npm install -g @anthropic-ai/claude-code` with no version pin, so those registry-sourced layers were the weakest link in an image whose direct toolchain downloads already go through `runtime/fetch-verify.sh` with a required, pinned SHA-256. Each `FROM` now appends an `@sha256:` digest after the tag, so Docker rejects any registry response whose bytes do not hash to the pinned digest and the base layers are reproducible; the tag is kept next to the digest for readability, and a comment says to refresh the digest when the tag is bumped deliberately. The three global installs pin exact versions through the build ARGs `PNPM_VERSION`, `YARN_VERSION`, and `CLAUDE_CODE_VERSION` (`pnpm@${PNPM_VERSION}`, `yarn@${YARN_VERSION}`, and `@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`), and npm verifies each pinned version's tarball integrity against the registry. Tests: `e2e/tests/09_dockerfile_fetch_verify.sh` and `integration/release-workflow.test.ts` (every `FROM` carries an `@sha256:` digest with no bare mutable tag left, each of the three version ARGs has a non-empty default, and no `npm install -g` of the registry packages `pnpm` / `yarn` / `@anthropic-ai/claude-code` is left without an `@version`), and the CI `docker build` of `runtime/Dockerfile` exercises the pinned inputs end to end. Docs: the new **Dockerfile base-image and npm pinning** note in [Contributing](docs/contributing.md#library-registry-signing). (Security review 2026-07-31, ASI-09, LOW, confidence 0.75.) + +- **Security — pin an explicit OIDC JWT algorithms allowlist in `jwtVerify` (finding L-3):** `createOidcAuthenticator` (`src/cli/serve/auth.ts`) called `jwtVerify(token, keys, { issuer, audience })` with no `algorithms` option, so the accepted signing algorithm was left to `jose`'s defaults. It was not exploitable — `jose` rejects `alg: none` and type-matches the JWK to the algorithm, so an RS256↔HS256 confusion is not reachable with a remote JWKS — but nothing pinned the set as defense-in-depth against a future key-type or JWKS change. The new exported constant `OIDC_JWT_ALGORITHMS` lists the accepted asymmetric algorithms (`RS256` / `RS384` / `RS512`, `PS256` / `PS384` / `PS512`, `ES256` / `ES384` / `ES512`, and `EdDSA`) and deliberately omits the symmetric `HS*` family, `alg: none`, and the non-recommended secp256k1 curve `ES256K`; the new exported `oidcVerifyOptions(cfg)` returns `{ issuer, audience, algorithms: [...OIDC_JWT_ALGORITHMS] }`, and `jwtVerify` is now called with it. The array is copied on every call so `jose` can never mutate the exported source of truth. A token whose header names an algorithm outside the allowlist is now rejected `401 E_TOKEN_INVALID` even when its signing key is in the served JWKS. Tests: `src/cli/serve/auth.test.ts` (`oidcVerifyOptions` pins `issuer` and `audience`, carries a non-empty algorithms allowlist that includes `RS256` and `ES256`, never includes `HS256` / `HS384` / `HS512` / `none` / `ES256K`, and returns a fresh copy of the constant) and `integration/serve-auth.test.ts` (the OIDC token matrix adds a disallowed-algorithm case — a token signed with a real `ES256K` key whose public half IS in the JWKS is rejected `401 E_TOKEN_INVALID`, while the existing RS256 happy path still authenticates). Docs: the algorithms-allowlist note in [Serve workflows over HTTP](docs/serve.md) and the OIDC verification list and `E_TOKEN_INVALID` error note in [CLI — `jaiph serve`](docs/cli.md#jaiph-serve). (Security review, ASI-08, LOW, confidence 0.72.) + +- **Security — add a host-mode wall-clock run timeout and an optional max-step circuit breaker (finding L-2):** the Docker run timeout (`runtime.docker_timeout_seconds` / `JAIPH_DOCKER_TIMEOUT`) bounded only Docker mode (`src/runtime/docker.ts`); the host spawn in `runWorkflow` (`src/cli/commands/run.ts`) and `callWorkflowHost` (`src/cli/exec/call.ts`) installed only SIGINT / SIGTERM handlers, and the per-prompt idle watchdog covered a single backend call, so the only automatic stop for a host / `--unsafe` run was a manual Ctrl-C, with no overall wall-clock cap, no step or iteration bound, and no circuit breaker. `armRunTimeout` and `parseRunTimeoutSeconds` (`src/cli/run/lifecycle.ts`) now arm a parent-enforced wall-clock timer from `JAIPH_RUN_TIMEOUT` (seconds; `0`, empty, or invalid disables it) on the host run child: `runWorkflow` arms it for host mode only (Docker mode skips it to avoid a double timer, so `JAIPH_DOCKER_TIMEOUT` stays the Docker backstop), and `callWorkflowHost` arms the same timer on the host spawn a `jaiph serve` / `jaiph mcp` call uses. On expiry the timer terminates the child's whole process group through `killProcessTree` (`SIGTERM`, then `SIGKILL` after a grace period, the same escalation as a CLI signal) and the failure footer shows `E_RUN_TIMEOUT` (`formatRunTimeoutMessage`, `src/cli/shared/errors.ts`); the timer is cleared once the child exits, so a completed run leaves nothing pending. Separately, `parseMaxSteps` and `maxStepsTrippedMessage` (`src/runtime/kernel/max-steps.ts`) add an optional max-step circuit breaker from `JAIPH_MAX_STEPS` (`0`, empty, or invalid disables it): `NodeWorkflowRuntime` increments one `stepsExecuted` counter on every executed non-trivia step across the whole run (loop iterations and nested or recursive calls share it), and once the count exceeds the cap it emits `E_MAX_STEPS` as a `LOGERR`, calls `abort()`, and returns a failure step result, so a runaway loop or recursion stops without a manual signal. Tests: `src/cli/run/lifecycle.test.ts` (`parseRunTimeoutSeconds` disables on unset / empty / invalid / non-positive and floors a positive value, a disabled `armRunTimeout` returns an inert handle, the parent terminates a real host child after the budget with no Ctrl-C, and a child that exits before the budget is never signalled), `src/cli/exec/call-run-timeout.test.ts` (a `callWorkflow` serve/mcp host run exceeding `JAIPH_RUN_TIMEOUT` is terminated), `src/runtime/kernel/max-steps.test.ts` and `src/runtime/kernel/node-workflow-runtime.max-steps.test.ts` (`parseMaxSteps` parsing, and a runaway loop trips the breaker and ends the run non-zero while the same loop runs to completion when the breaker is unset), and `src/runtime/docker.test.ts` (a regression asserts the Docker timeout path still force-removes the container by name, and `timeoutSeconds=0` arms no timer). Docs: the new [Overall run timeout and step cap](docs/configuration.md#overall-run-timeout-and-step-cap) section in [Configuration](docs/configuration.md), the `JAIPH_RUN_TIMEOUT` and `JAIPH_MAX_STEPS` rows in [Environment variables](docs/env-vars.md), the host-run note in [Sandboxing](docs/sandboxing.md), and the run-timeout kill-site and circuit-breaker notes in [Architecture](docs/architecture.md). (Security review, ASI-10, LOW, confidence 0.85.) + +- **Feat — Kill a leaf `script` step after a long idle-output window:** the idle-output tracker (`createStepIdleOutputWarn` in `src/runtime/kernel/step-idle-warn.ts`) previously only warned, so a step that stopped producing output could hold an overnight run open indefinitely because no default host timeout stopped a stuck leaf (an overnight `engineer.jh` hung on `npm run test:ci` with no progress for about an hour). The tracker now also enforces a kill threshold parsed by `parseStepIdleKillSec` from `JAIPH_STEP_IDLE_KILL_SEC` (default `3600`; empty or invalid falls back to the default; `0` disables). When the tracker is constructed with an `onIdleKill` callback and the step is silent for that long, it emits a `LOGERR` naming the step and idle duration, invokes the callback once, and stops ticking. The warn cadence (`JAIPH_STEP_IDLE_WARN_SEC`) runs off the same idle clock but fires independently, the kill fires at most once, and any new stdout/stderr chunk resets both through the existing `bump()`. In `NodeWorkflowRuntime.executeManagedStep` the callback aborts an `AbortController` whose signal is threaded to `spawnAndCapture` for `script` steps only; aborting it terminates the step's subprocess through `killProcessTreeEscalating` (`src/runtime/kernel/portability.ts`, SIGTERM then SIGKILL), destroys the child's stdout/stderr pipes so a hung descendant that outlived the child while holding the pipe open cannot keep the run stuck, and settles the step as a failure (`status: 1`) without waiting for `close`. Prompt steps drive no subprocess, so they keep warn-only behaviour. Tests: `src/runtime/kernel/step-idle-warn.test.ts` (`parseStepIdleKillSec` defaults to 3600 and honours `0`, a kill fires after the threshold and only once, and `JAIPH_STEP_IDLE_KILL_SEC=0` leaves warn-only behaviour) and `src/runtime/kernel/node-workflow-runtime.idle-kill.test.ts` (a real script step with no output is terminated with a `LOGERR` and fails, while a step that keeps emitting output resets the clock and runs to completion). Docs: the new [Leaf step idle output](docs/configuration.md#leaf-step-idle-output) section in [Configuration](docs/configuration.md), the `JAIPH_STEP_IDLE_KILL_SEC` row in [Environment variables](docs/env-vars.md), the idle-kill note on the heartbeat and idle-warning line of [CLI](docs/cli.md), and the idle-step warnings and kill note in [Architecture](docs/architecture.md). + +- **Feat — self-host the `jaiph serve` Swagger UI so `/docs` needs no browser internet access:** the `/docs` shell (`src/cli/serve/docs.ts`) loaded `swagger-ui-dist` from a pinned `cdn.jsdelivr.net` URL with a Subresource Integrity hash and `crossorigin`, so an air-gapped browser, an offline host, or a Content-Security-Policy that blocks third-party hosts rendered a blank page and left only `/openapi.json` usable. The two pinned assets, `swagger-ui-bundle.js` and `swagger-ui.css`, are now embedded into the binary through the existing embed pipeline: `tools/embed-assets.js` reads them from the pinned `swagger-ui-dist` devDependency into `src/runtime/embedded-assets.ts`, and the handler serves them from same-origin paths (`GET /docs/swagger-ui-bundle.js` and `GET /docs/swagger-ui.css`), so the browser never fetches from a third-party host. Each `` / `