feat: janus init onboarding wizard for the Claude Code CLI - #5
Merged
Merged
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
Re-running the payload-shape capture against CLI 2.1.278 (fixtures were pinned at 2.1.233) turned up two defects on the Claude Code CLI path. The visible one: `SubagentHandback` — how a subagent delivers its report to its caller — is CLI-internal transport that strict default-deny blocked, stranding every subagent's work under mode="policy". It reaches no resource, so it joins `ToolSearch` in DEFAULT_CLI_PASSTHROUGH_TOOLS. The one that matters: the taint source for subagent output moved. On 2.1.233 the subagent's report came back in the parent's PostToolUse[Agent].tool_response.content. On 2.1.278 that field is a placeholder pointing at the handback call (new `handback: "send"` key), and the report travels in SubagentHandback's tool_input.message — its *input*, while its response is only a delivery receipt. A TaintTracker sourcing `Agent` therefore recorded a fixed placeholder sentence and lost the subagent's content entirely: no error, no missing key, nothing failing, every downstream sink left open. Same failure shape as the Windows `/`-anchored path patterns and the as_posix() probes — a mechanism reporting green while measuring nothing. CLI_INPUT_SOURCE_TOOLS makes the recording seam read the input for such tools, gated on PostToolUse so a denied or failed handback records nothing, and warning rather than skipping quietly when the named argument goes missing — that silence is how this class of bug survives. Deployments wanting subagent output to taint must now list SubagentHandback as a source; naming `Agent` alone no longer reaches that content. Three fixtures captured verbatim from the live 2.1.278 session pin all of it. Answers open question (7) in plans/claude-code-plugin-design.md. Validation: pytest (351 passed, 7 skipped), ruff check, mypy, and the enforcement-review checklist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes, all found by pointing a live `claude` session at what the
wizard produced. They are one commit because fix 2 is what proves fix 1,
and both reshape the same verify() path.
1. The written hook command could name an executable the session cannot
find. Claude Code runs hooks through its own shell, with the PATH of the
session started later. `janus init` is normally run as `uv run janus
init`, which puts the venv's bin on PATH, so which("janus-hook")
succeeded and a bare `janus-hook` was written. From an ordinary shell
that exits 127, and a non-zero-but-not-2 hook exit is a non-blocking
error: the tool runs, nothing is enforced, no error is shown. Confirmed
live — a Read of .env the policy denies succeeded.
Private scopes (user, project-local) now get the resolved absolute path;
those files are machine-specific anyway. Shared `project` scope keeps the
portable bare name, since an absolute venv path would be wrong for every
teammate, and verification warns loudly instead. A _hook_is_reachable
warning for precisely this failure already existed and never fired: it
inspected the wizard's PATH rather than the one that decides.
2. verify() asked the policy, not the deployment. It called
handle_cli_payload in process — "would this policy deny this payload" —
which cannot see a command the session will never run. It now executes
the exact command string with CLAUDE_PROJECT_DIR set as the CLI sets it,
payloads on stdin, failing on non-zero exit, unparseable stdout (making
the shim's stdout-isolation property a standing check) or a wrong
decision. One extra probe re-runs with this venv stripped from PATH,
standing in for a plain shell; one probe settles it, since the question
is binary.
3. Bash could read the secrets Read was denied. .env and *.pem were in
SECRET_READ_PATTERN and missing from BASH_EXFIL_PATTERN, so `Read` on
.env was denied while `cat .env` was allowed. A live agent refused Read
went straight to Bash and returned the contents — against a policy whose
verification had just printed "reading a .env file is denied". Both are
now in the Bash deny in command-line form (\.pem\b, not the end-anchored
\.pem$ a path uses), the .env.example exemption is preserved, and probes
cover the Bash route to each secret. Probe labels name the tool they
tested, because the old wording read as coverage that did not exist.
The same meta-bug produced all three, and the Windows as_posix() probes and
the Agent/SubagentHandback taint source before them: verification that
exercises something other than what is deployed. Executing the command is
the version of this check that cannot drift.
docs/claude-code-deployment.md gains the scope/PATH rule and a section
stating plainly what shell argument-matching can and cannot promise —
including that it denies `ls -la .env`, which is deliberate.
Validation: pytest (374 passed, 7 skipped), ruff, mypy, and a live re-run of
the wizard in both scopes against CLI 2.1.278 — the clean-PATH `claude`
session that previously read .env unimpeded is now denied.
Co-Authored-By: Claude Opus 5 (1M context) <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 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.Part 3 — Two follow-up fixes from live sessions against the wizard's output
4. Subagents were stranded, and their output silently escaped taint tracking
Re-capturing payload shapes against CLI 2.1.278 (fixtures were pinned at 2.1.233) surfaced two defects on the CLI path:
SubagentHandback— the CLI-internal transport a subagent uses to deliver its report — was blocked by strict default-deny undermode="policy", stranding every subagent's work. It reaches no resource, so it joinsToolSearchinDEFAULT_CLI_PASSTHROUGH_TOOLS.PostToolUse[Agent].tool_response.contentis a placeholder pointing at the handback call; the actual report travels inSubagentHandback'stool_input.message. ATaintTrackersourcingAgentrecorded a fixed sentence and lost the content entirely — no error, every downstream sink left open.CLI_INPUT_SOURCE_TOOLSmakes the recording seam read the input for such tools, gated onPostToolUseso a denied handback records nothing, and warns rather than skipping silently when the argument is missing. Deployments wanting subagent output to taint must now listSubagentHandbackas a source.Three fixtures captured verbatim from the 2.1.278 session pin the behavior; answers open question (7) in
plans/claude-code-plugin-design.md.5.
janus initverified the policy it named, not the deployment it wrotePointing a live
claudesession at the wizard's output showed the closing checks could printPASSwhile the hook never ran.janus initis normally run viauv run, sowhich("janus-hook")succeeded and a bare command was written that an ordinary shell cannot resolve.verify()now executes the exact written command string withCLAUDE_PROJECT_DIRset, a_hook_is_reachablecheck warns loudly when the executable will not resolve in a plain shell, and the review screen reflects what was actually deployed. Details inCHANGELOG.md.Test plan (updated)
uv run pytest— 374 passed, 7 skipped on the branch headuv run ruff check .anduv run mypy januscleanTest plan (original)
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