feat: janus init onboarding wizard for the Claude Code CLI - #5
Open
ish-codes-magic wants to merge 1 commit into
Open
feat: janus init onboarding wizard for the Claude Code CLI#5ish-codes-magic wants to merge 1 commit into
janus init onboarding wizard for the Claude Code CLI#5ish-codes-magic wants to merge 1 commit into
Conversation
Setting Janus up on the Claude Code CLI meant hand-writing a policy, pasting a
hooks block into a settings file, and merging the backstop by hand. A guard
nobody finishes installing protects nothing.
`janus init` asks a handful of questions with safe defaults (scope, what to
protect, network posture, git posture, MCP servers, strictness), shows the exact
settings diff, and on confirmation writes the policy, the PreToolUse entry, and
the permissions.deny backstop. It then verifies by feeding synthetic payloads
through handle_cli_payload with the flags it just wrote, so a PASS reflects the
deployed decision path rather than the wizard's intent.
New modules, all under janus/cli/ so enforcement semantics are untouched:
- starter_policy.py builder over rule fragments; a parity test pins its
defaults to examples/claude_code/policy.starter.json so the
file users copy and the file the wizard writes cannot drift
- claude_settings.py read/merge/backup/atomic-write; idempotent hook upsert keyed
on the command, additive permissions.deny, foreign content
never touched
- _console.py stdlib prompts, no new dependency
- init.py the flow, the review screen, and the verification probes
- main.py the `janus` umbrella; janus-hook stays a pure decision
process with no interactive surface
Also: hook entries now carry an explicit timeout (the docs demanded one above
--deadline but no example ever showed it), hook._doctor is public as run_doctor
so `janus doctor` and the wizard share it, and generated hook commands are
shell-quoted -- a command the shell mis-parses is a hook that never runs, and
hook dispatch failure fails open.
Validation: 324 passed, 9 skipped; ruff check clean; mypy clean on the new
modules. Live smoke on Windows: dry-run, real run with all 7 probes passing, and
the wired command denying `curl | sh` while allowing ordinary reads.
Pre-existing and untouched: tests/test_claude_code_shim.py::TestDeadline fails on
Windows because _deadline needs SIGALRM; confirmed failing on clean HEAD.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Janus's Claude Code CLI deployment assumed the operator already knew what to allow:
janus-hookrequires a hand-written--policy, and the documented setup (Getting Started) was four manual steps ending in a JSON block pasted into a settings file by hand. A guard nobody finishes installing protects nothing.This adds
janus init— an interactive onboarding wizard, inspired by Claude Code's own auto-mode setup — behind a newjanusumbrella console script. It asks ~8 questions with safe Enter-defaults, shows the exact diff of what it's about to change, and on confirmation writes a complete tier-1 deployment: policy,PreToolUsehook wiring, and thepermissions.denybackstop. It then verifies its own work by running the deployed decision path against synthetic payloads, so aPASSmeans that policy actually denied that call — not that the wizard believes it would.What's new
All new code lives under
janus/cli/, deliberately outsidejanus/policy/andjanus/adapters/— the wizard is a pure producer/consumer of existing enforcement APIs and touches no enforcement semantics.janus/cli/starter_policy.pyexamples/claude_code/policy.starter.jsonso the file users copy by hand and the file the wizard writes can never drift apart.janus/cli/claude_settings.py.claude/settings*.json. The hook upsert is idempotent (keyed on the command string, not a marker — re-running updates in place, never stacks a second hook),permissions.denymerges additively, and foreign hooks/keys are never touched. Every write backs up the previous file first and lands viaos.replace.janus/cli/_console.py[y/N]) — no new dependency. Core install staysjsonschema+pydantic.janus/cli/init.pyjanus/cli/main.pyjanusumbrella script (init,doctor).janus-hookis untouched — it stays a pure decision process with no interactive surface, since mixing a prompt loop into the process Claude executes on every tool call is how you eventually print a question where a decision belongs.Also:
janus/cli/hook.py:_doctor→ publicrun_doctor, shared byjanus-hook doctorandjanus doctor(mechanical rename, existing tests unchanged)."timeout"— the docs demanded one above--deadlinebut no example anywhere in the repo actually showed the key. Fixed in the docs too.shlex.quoteon POSIX; Windows gets"..."and refuses a path containing a literal quote rather than emitting a command that would silently fail to parse). A command the shell mis-parses is a hook that never runs — and hook dispatch failure on this seam fails open.Design decisions worth flagging
generateextra + an API key are present) that drafts argument-level rules via the existinggenerate_policy(). Its output lands at priority 100 — behind the starter's unconditional allow@10 — so naively appending would produce rules that can never match. Accepting therefore replaces the affected tool's blanket allow with the generated conditions, and the review screen states that inversion in plain words before anyone confirms.PreToolUsepayloads and feed them throughhandle_cli_payloadwith the exact--mode/--headless/--configflags just written.--deadlineneedsSIGALRMand is a no-op on Windows, so a wedged decision there falls through to Claude's own hook timeout, which fails open. A Windows wizard run prints this and points at thepermissions.denybackstop as the layer that still holds.Docs
docs/getting-started.md— leads withjanus init, keeps the four manual steps under "Doing it by hand."docs/claude-code-deployment.md— new "Wizard setup" section: what it touches, idempotency, backup naming, PATH caveats.docs/adapters.md,README.md,examples/claude_code/README.md,CHANGELOG.mdupdated to match.Test plan
uv run pytest— 324 passed, 9 skipped, 1 pre-existing failure (see below), offlineuv run ruff check .— cleanuv run mypy janus/cli— clean on all 5 new modulestests/test_cli_init.py(68 tests): starter-policy parity + full-form invariants, settings-merge idempotency/foreign-content-preservation/backup, console helpers, non-interactive/--dry-run/--yesflows, scripted-stdin wizard flow incl. MCP sidecar, verification PASS/FAIL against the real decision path, LLM-assist skip/accept/decline, hook-command shell-quoting on both POSIX and Windows branchestests/test_import_hygiene.py— new checks thatjanus.cli.mainimports on a core install andjanus.cli.initnever eagerly imports the generator or its depsenforcement-reviewskill run over the diff — all invariants (default-deny, strict conditions, tie-break, fail-closed, no global state, audit completeness) hold; nothing injanus/policy/orjanus/adapters/touchedjanus init --dry-run, then a real run into a scratch project — all 7 verification probes passed, and the actual wiredjanus-hookcommand was fed real payloads directly (curl | sh→ denied, ordinaryRead→ allowed)Known pre-existing failure, unrelated to this change:
tests/test_claude_code_shim.py::TestDeadline::test_slow_decision_denies_rather_than_overrunningfails on Windows because_deadline()needssignal.SIGALRM, which Windows doesn't have — confirmed failing on cleanmainbefore this branch. Left untouched; the wizard's closing output now discloses the consequence (deadline is inert on Windows, backstop is what holds) rather than hiding it.🤖 Generated with Claude Code