Skip to content

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

Open
ish-codes-magic wants to merge 2 commits into
mainfrom
feat/janus-init
Open

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

Conversation

@ish-codes-magic

@ish-codes-magic ish-codes-magic commented Aug 30, 2026

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 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 new janus umbrella 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, PreToolUse hook wiring, and the permissions.deny backstop. It then verifies its own work by running the deployed decision path against synthetic payloads.

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)

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 outside janus/policy/ and janus/adapters/: the wizard is a pure producer/consumer of existing enforcement APIs and changes 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.
janus/cli/claude_settings.py Read/merge/backup/atomic-write for .claude/settings*.json. The hook upsert is idempotent (keyed on the command string — re-running updates in place, never stacks a second hook), permissions.deny merges additively, foreign hooks and keys are never touched. Every write backs up the previous file and lands via os.replace.
janus/cli/_console.py Stdlib-only prompts — no new dependency. The core install stays jsonschema + pydantic.
janus/cli/init.py Question flow, review screen (unified diff before any write), post-write verification probes.
janus/cli/main.py The janus umbrella (init, doctor). janus-hook is untouched — it stays a pure decision process with no interactive surface.

Design decisions worth a reviewer's attention:

  • LLM-assist layering. The optional branch (offered only when the generate extra and an API key are both present) drafts rules via the existing generate_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.
  • Verification runs the deployed path, feeding payloads through handle_cli_payload with the exact flags just written.
  • Write ordering is deliberate: policy first, wiring last. Reversed, a crash in between leaves a hook pointing at a nonexistent policy — and since the shim fails closed on an unreadable policy, every tool call in the next session would be denied.
  • Hook entries now always carry an explicit "timeout" — the docs demanded one above --deadline but no example in the repo ever showed the key.
  • Generated hook commands are shell-quoted (shlex.quote on 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_path with the host's separator. Verified against a live CLI 2.1.246 session, which sent C:\Users\...\README.md. The starter policy anchored on /. Against the previous starter, on Windows, all of these were allowed:

Attempted call Windows (\) POSIX (/)
Read .env ALLOWED DENY
Read ~/.ssh/id_rsa ALLOWED DENY
Read ~/.aws/credentials ALLOWED DENY
Read ~/.claude/.credentials.json ALLOWED DENY
Write .claude/settings.json (anti-tamper) ALLOWED DENY
Read server.pem DENY DENY

Only \.pem$ held — the one pattern needing no separator. The bug predates this branch (it is in policy.starter.json on main), 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_probes rendered paths with as_posix(), so on Windows it probed 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. Probes now use the host's native separator.

3. The janus-hook deadline was inert on Windows

_deadline needs SIGALRM, 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.yml was ubuntu-latest with a matrix over Python versions only. CI now includes windows-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_scope is covered separately on both branches.


Docs

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

Test plan

  • uv run pytest342 passed, 9 skipped; the suite is green on Windows for the first time (the pre-existing TestDeadline failure is fixed by bug 3)
  • uv run ruff check . clean; uv run mypy janus clean (the signal.setitimer errors 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 / --yes flows, 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_scope on both branches, Windows payload regression suite
  • tests/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_sigalrm so they run on every platform
  • tests/test_import_hygiene.py: janus.cli.main imports on a core install; janus.cli.init never eagerly imports the generator or its deps
  • enforcement-review skill run over the diff — default-deny, strict conditions, tie-break, fail-closed, no global state, and audit completeness all hold
  • Live end-to-end on Windows: wizard run into a scratch project, then a real claude session 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

ish-codes-magic and others added 2 commits August 26, 2026 11:07
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>
@ish-codes-magic ish-codes-magic changed the title feat: janus init onboarding wizard for the Claude Code CLI feat: janus init onboarding wizard for the Claude Code CLI (+ Windows path-policy fix) Aug 31, 2026
@ish-codes-magic ish-codes-magic changed the title feat: janus init onboarding wizard for the Claude Code CLI (+ Windows path-policy fix) feat: janus init onboarding wizard for the Claude Code CLI Sep 1, 2026
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