Skip to content

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

Merged
eharris128 merged 4 commits into
mainfrom
feat/janus-init
Sep 21, 2026
Merged

eharris128 merged 4 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.

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 under mode="policy", stranding every subagent's work. It reaches no resource, so it joins ToolSearch in DEFAULT_CLI_PASSTHROUGH_TOOLS.
  • The taint source for subagent output moved. On 2.1.278 PostToolUse[Agent].tool_response.content is a placeholder pointing at the handback call; the actual report travels in SubagentHandback's tool_input.message. A TaintTracker sourcing Agent recorded a fixed sentence and lost the content entirely — no error, every downstream sink left open. CLI_INPUT_SOURCE_TOOLS makes the recording seam read the input for such tools, gated on PostToolUse so a denied handback records nothing, and warns rather than skipping silently when the argument is missing. Deployments wanting subagent output to taint must now list SubagentHandback as 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 init verified the policy it named, not the deployment it wrote

Pointing a live claude session at the wizard's output showed the closing checks could print PASS while the hook never ran. janus init is normally run via uv run, so which("janus-hook") succeeded and a bare command was written that an ordinary shell cannot resolve. verify() now executes the exact written command string with CLAUDE_PROJECT_DIR set, a _hook_is_reachable check warns loudly when the executable will not resolve in a plain shell, and the review screen reflects what was actually deployed. Details in CHANGELOG.md.

Test plan (updated)

  • uv run pytest — 374 passed, 7 skipped on the branch head
  • uv run ruff check . and uv run mypy janus clean

Test plan (original)

  • uv run pytest — 342 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
eharris128 and others added 2 commits September 20, 2026 21:42
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>
@eharris128
eharris128 merged commit 062f813 into main Sep 21, 2026
5 checks passed
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.

2 participants