feat: janus init onboarding wizard for the Claude Code CLI - #5
Open
ish-codes-magic wants to merge 2 commits into
Open
feat: janus init onboarding wizard for the Claude Code CLI #5ish-codes-magic wants to merge 2 commits into
janus init onboarding wizard for the Claude Code CLI #5ish-codes-magic wants to merge 2 commits 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>
Claude Code reports `tool_input.file_path` with the host's separator. Verified
against a live CLI 2.1.246 session on Windows: it sends
`C:\Users\...\README.md`. The starter policy anchored its path rules on `/`, so
on Windows those rules matched nothing.
Against the previous starter, on Windows, all of these were ALLOWED:
Read C:\Users\me\proj\.env
Read C:\Users\me\.ssh\id_rsa
Read C:\Users\me\.aws\credentials
Read C:\Users\me\.claude\.credentials.json
Write C:\proj\.claude\settings.json <- the anti-tamper rule
Only `\.pem$` held, being the one pattern needing no separator.
Worse, `janus init` reported this as healthy. Its verification probes built
paths with as_posix(), so they exercised forward slashes while the deployment
received backslashes: seven green PASS lines over a policy that was allowing
`.env` reads. A guard that fails silently is bad; one that reports success
while failing is worse.
Fixes:
- starter_policy: path patterns use a separator class (SEP = `[/\\]`), and
user-typed entries are normalized the same way, so `secrets/` typed on any
host matches a path reported by any host. policy.starter.json regenerated
from the builder; the parity test keeps them pinned.
- init: probes render paths with str(Path) — the host's native separator —
so verification exercises what the CLI actually sends.
- hook: the `--deadline` needed SIGALRM and so did nothing on Windows, letting
a wedged decision run until the CLI's hook timeout, which fails OPEN. A
worker-thread fallback restores the fail-closed property. This also fixes the
one test that had been failing on Windows since before this branch.
- tests: four end-to-end tests hardcoded `settings.local.json`, the *Windows*
scope default, and would have failed on Linux CI. They now name the scope;
`_default_scope` is covered separately on both branches.
- CI: matrix gains windows-latest. Every bug above is platform-specific and a
Linux-only matrix could not see any of them — which is exactly how they got
here.
New Windows payload fixture captured from a live session, plus regression tests
driving the real dispatcher with backslash paths.
Validation: 342 passed, 9 skipped (full suite now green on Windows for the
first time); ruff and mypy clean. Live end-to-end: a real `claude` session was
blocked reading `.env` — `[Janus] blocked by policy: Tool 'Read' matched a deny
rule` — where before the fix the same read succeeded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
janus init onboarding wizard for the Claude Code CLIjanus init onboarding wizard for the Claude Code CLI (+ Windows path-policy fix)
janus init onboarding wizard for the Claude Code CLI (+ Windows path-policy fix)janus init onboarding wizard for the Claude Code CLI
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 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 behind a newjanusumbrella console script. It asks ~8 questions with safe Enter-defaults, shows the exact diff of what it is 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.Building it surfaced three platform bugs, one of them a live security hole — see Part 2.
Part 1 — The wizard
All new code lives under
janus/cli/, deliberately outsidejanus/policy/andjanus/adapters/: the wizard is a pure producer/consumer of existing enforcement APIs and changes 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.janus/cli/claude_settings.py.claude/settings*.json. The hook upsert is idempotent (keyed on the command string — re-running updates in place, never stacks a second hook),permissions.denymerges additively, foreign hooks and keys are never touched. Every write backs up the previous file and lands viaos.replace.janus/cli/_console.pyjsonschema+pydantic.janus/cli/init.pyjanus/cli/main.pyjanusumbrella (init,doctor).janus-hookis untouched — it stays a pure decision process with no interactive surface.Design decisions worth a reviewer's attention:
generateextra and an API key are both present) drafts rules via the existinggenerate_policy(). Its output lands at priority 100 — behind the starter's unconditional allow@10 — so appending would produce rules that can never match. Accepting therefore replaces the affected tool's blanket allow, and the review screen states that inversion in plain words before anyone confirms.handle_cli_payloadwith the exact flags just written."timeout"— the docs demanded one above--deadlinebut no example in the repo ever showed the key.shlex.quoteon POSIX; Windows gets"..."and refuses a path containing a literal quote). A command the shell mis-parses is a hook that never runs, and hook dispatch failure fails open.Part 2 — Three platform bugs found while testing on Windows
1. Path policies matched nothing on Windows (security hole)
Claude Code reports
tool_input.file_pathwith the host's separator. Verified against a live CLI 2.1.246 session, which sentC:\Users\...\README.md. The starter policy anchored on/. Against the previous starter, on Windows, all of these were allowed:\)/).env~/.ssh/id_rsa~/.aws/credentials~/.claude/.credentials.json.claude/settings.json(anti-tamper)server.pemOnly
\.pem$held — the one pattern needing no separator. The bug predates this branch (it is inpolicy.starter.jsononmain), but the wizard would have propagated it to every new user.Fix: path patterns use a separator class (
SEP = [/\\]), user-typed entries are normalized the same way, and the starter JSON is regenerated from the builder so the parity test keeps them pinned.2. The wizard's own verification reported PASS against paths the CLI never sends
build_probesrendered paths withas_posix(), so on Windows it probed forward slashes while the deployment received backslashes — seven greenPASSlines over a policy that was allowing.envreads. A guard that fails silently is bad; one that reports success while failing is worse. Probes now use the host's native separator.3. The
janus-hookdeadline was inert on Windows_deadlineneedsSIGALRM, so on Windows it degraded to no deadline at all — a wedged decision ran until the CLI's own hook timeout, which fails open. A worker-thread fallback restores the fail-closed property. This also fixes the one test that had been failing on Windows since before this branch.Why none of it was caught
Every payload fixture in
tests/fixtures/was captured on Linux, and.github/workflows/test.ymlwasubuntu-latestwith a matrix over Python versions only. CI now includeswindows-latest. A new fixture captured from a live Windows session (pretooluse.windows-read.json) pins the separator behavior, with regression tests driving the real dispatcher using backslash paths.Symmetrically, four of my own end-to-end tests hardcoded
settings.local.json— the Windows scope default — and would have failed on Linux CI. They now name the scope explicitly;_default_scopeis covered separately on both branches.Docs
docs/getting-started.md— leads withjanus init; the four manual steps move under "Doing it by hand"; hooks snippet gains"timeout": 10.docs/claude-code-deployment.md— new "Wizard setup" section: what it touches, idempotency, backup naming, PATH caveat, separator requirement.examples/claude_code/README.md— new "Match both path separators" pattern, with the bug above as the worked example. Also corrects a claim that anchoring prevents.envfrom matching.environment(it does not; anchoring bounds where a match may start, not that it is exact).tests/fixtures/claude_code_payloads/README.md— provenance for the Windows capture and the separator finding.docs/adapters.md,README.md,CHANGELOG.mdupdated to match.Test plan
uv run pytest— 342 passed, 9 skipped; the suite is green on Windows for the first time (the pre-existingTestDeadlinefailure is fixed by bug 3)uv run ruff check .clean;uv run mypy janusclean (thesignal.setitimererrors on Windows are now explicitly ignored, with the runtime guard documented)tests/test_cli_init.py(82 tests): starter-policy parity and full-form invariants, both-separator matching for secret and guard-tamper denies, settings-merge idempotency and foreign-content preservation, console helpers, non-interactive /--dry-run/--yesflows, scripted-stdin wizard flow, verification PASS/FAIL against the real decision path, LLM-assist skip/accept/decline, hook-command quoting on both platform branches,_default_scopeon both branches, Windows payload regression suitetests/test_claude_code_shim.py: three new tests for the worker-thread deadline (it fires; it propagates a real error rather than swallowing it; it returns a normal decision unharmed), forced on via_has_sigalrmso they run on every platformtests/test_import_hygiene.py:janus.cli.mainimports on a core install;janus.cli.initnever eagerly imports the generator or its depsenforcement-reviewskill run over the diff — default-deny, strict conditions, tie-break, fail-closed, no global state, and audit completeness all holdclaudesession asked to read.env→ blocked with[Janus] blocked by policy: Tool 'Read' matched a deny rule.The same read succeeded before the fix.🤖 Generated with Claude Code