Skip to content

feat: janus init onboarding wizard for the Claude Code CLI - #5

Open
ish-codes-magic wants to merge 1 commit into
mainfrom
feat/janus-init
Open

feat: janus init onboarding wizard for the Claude Code CLI#5
ish-codes-magic wants to merge 1 commit into
mainfrom
feat/janus-init

Conversation

@ish-codes-magic

Copy link
Copy Markdown
Collaborator

Summary

Janus's Claude Code CLI deployment assumed the operator already knew what to allow: janus-hook requires 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 new janus umbrella 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, PreToolUse hook wiring, and the permissions.deny backstop. It then verifies its own work by running the deployed decision path against synthetic payloads, so a PASS means that policy actually denied that call — not that the wizard believes it would.

pip install janus-guard
janus init              # asks questions, shows a diff, writes on confirmation
janus init --dry-run    # preview only
janus init --yes        # accept every default (CI)

What's new

All new code lives under janus/cli/, deliberately outside janus/policy/ and janus/adapters/ — the wizard is a pure producer/consumer of existing enforcement APIs and touches no enforcement semantics.

Module Role
janus/cli/starter_policy.py Programmatic builder over rule fragments (deny@1 + allow@10 + bypass-mode tool enumeration). A parity test pins its defaults to examples/claude_code/policy.starter.json so the file users copy by hand and the file the wizard writes can never drift apart.
janus/cli/claude_settings.py Read/merge/backup/atomic-write for .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.deny merges additively, and foreign hooks/keys are never touched. Every write backs up the previous file first and lands via os.replace.
janus/cli/_console.py Stdlib-only prompt helpers (numbered menus, [y/N]) — no new dependency. Core install stays jsonschema + pydantic.
janus/cli/init.py The question flow, review screen (unified diff before any write), and the post-write verification probes.
janus/cli/main.py The janus umbrella script (init, doctor). janus-hook is 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 → public run_doctor, shared by janus-hook doctor and janus doctor (mechanical rename, existing tests unchanged).
  • Hook entries now always carry an explicit "timeout" — the docs demanded one above --deadline but no example anywhere in the repo actually showed the key. Fixed in the docs too.
  • Generated hook commands are shell-quoted (shlex.quote on 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

  • LLM-assist layering. There's an optional branch (only offered when the generate extra + an API key are present) that drafts argument-level rules via the existing generate_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.
  • Verification runs the deployed path, not an approximation. The closing checks build synthetic PreToolUse payloads and feed them through handle_cli_payload with the exact --mode/--headless/--config flags just written.
  • Windows caveat, stated rather than hidden. The shim's --deadline needs SIGALRM and 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 the permissions.deny backstop as the layer that still holds.

Docs

  • docs/getting-started.md — leads with janus 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.md updated to match.

Test plan

  • uv run pytest — 324 passed, 9 skipped, 1 pre-existing failure (see below), offline
  • uv run ruff check . — clean
  • uv run mypy janus/cli — clean on all 5 new modules
  • New tests/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/--yes flows, 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 branches
  • tests/test_import_hygiene.py — new checks that janus.cli.main imports on a core install and janus.cli.init never eagerly imports the generator or its deps
  • enforcement-review skill run over the diff — all invariants (default-deny, strict conditions, tie-break, fail-closed, no global state, audit completeness) hold; nothing in janus/policy/ or janus/adapters/ touched
  • Live smoke on Windows: janus init --dry-run, then a real run into a scratch project — all 7 verification probes passed, and the actual wired janus-hook command was fed real payloads directly (curl | sh → denied, ordinary Read → allowed)

Known pre-existing failure, unrelated to this change: tests/test_claude_code_shim.py::TestDeadline::test_slow_decision_denies_rather_than_overrunning fails on Windows because _deadline() needs signal.SIGALRM, which Windows doesn't have — confirmed failing on clean main before 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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant