Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@ on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Windows is not incidental coverage. Claude Code reports `file_path`
# with the host separator, so a Linux-only matrix once let a policy
# ship that matched nothing on Windows — every secret-read deny was
# silently allowed there. Path handling is platform-specific enough
# that it has to be exercised on both.
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
exclude:
# Windows runs the full version sweep on one interpreter; the
# platform-specific code paths do not vary by Python version, and
# eight Windows runners per PR buys nothing.
- os: windows-latest
python-version: "3.10"
- os: windows-latest
python-version: "3.11"
- os: windows-latest
python-version: "3.12"
steps:
- uses: actions/checkout@v5
- name: Setup uv
Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,32 @@ This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- **Path policies did not match on Windows — every secret-read deny was silently allowed
there.** Claude Code reports `tool_input.file_path` with the *host's* separator
(`C:\Users\...\.env`, verified against a live CLI 2.1.246 session), while the starter
policy anchored on `/`. Against the previous starter, reads of `.env`, `~/.ssh/id_rsa`,
`~/.aws/credentials` and `~/.claude/.credentials.json` were all **allowed** on Windows, as
were writes to `.claude/settings.json` — the rule meant to stop an agent disarming the
guard. Only `\.pem$` held, being the one pattern needing no separator. Path patterns now
use a separator class (`janus.cli.starter_policy.SEP`, `[/\\]`), user-supplied paths are
normalized the same way, and `examples/claude_code/policy.starter.json` is regenerated to
match. A Windows payload fixture captured from a live session
(`tests/fixtures/claude_code_payloads/pretooluse.windows-read.json`) pins it. The bug was
invisible because every prior fixture, and the whole CI matrix, was Linux.
- **`janus init` verification reported PASS against paths the CLI never sends.** Its probes
built paths with `as_posix()`, so on Windows they exercised forward slashes while the
deployment received backslashes — seven green checks on a policy that was allowing `.env`
reads. Probes now use the host's native separator.
- **The `janus-hook` deadline was inert on Windows.** `_deadline` needs `SIGALRM`, so on
Windows it degraded to no deadline at all, and a wedged decision ran until the CLI's own
hook timeout — which fails **open**. A worker-thread fallback restores the property: the
shim reaches its own limit and emits a deny while it still can. This also fixes the one
test that had been failing on Windows.
- CI now runs the suite on `windows-latest` as well as `ubuntu-latest`. All three bugs above
were platform-specific and a Linux-only matrix could not see any of them.

### Changed

- **BREAKING — `openai` and `jinja2` moved out of core dependencies** into the new `generate`
Expand Down Expand Up @@ -35,6 +61,24 @@ This project follows [Semantic Versioning](https://semver.org/).

### Added

- **`janus init` — an onboarding wizard, behind a new `janus` console script.** Setting Janus
up on the Claude Code CLI previously 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 (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 — with the explicit `timeout` the docs
always asked for and no example ever showed — 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.
Re-running updates the existing hook in place; foreign hooks, foreign deny entries, and
unrelated settings are never touched, and the previous file is backed up. `--dry-run`,
`--yes` (CI; a non-TTY without it is refused rather than defaulted), `--scope`, `--force`.
Optional: with the `generate` extra and an API key, it can draft argument-level rules for
review — accepting *replaces* a tool's blanket allow, since generated priority-100 rules
would otherwise sit unreachable behind it.
The `janus` script is deliberately separate from `janus-hook`, which stays a pure
decision process with no interactive surface. `janus doctor` delegates to the same
`janus.cli.hook.run_doctor` (renamed from `_doctor`) that `janus-hook doctor` uses.
- **Claude Code CLI adapter** (`janus.adapters.claude_code` + the `janus-hook` console script,
core install — no extra): enforce a Janus policy on the *interactive* `claude` CLI via its
`PreToolUse`/`PostToolUse` hooks. Unlike the SDK path, Janus does not construct the session
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -732,19 +732,33 @@ guarded = guard_tool_body("fetch_page", my_async_body, TOOL_POLICY,

The security model is genuinely weaker than the SDK path's, and the docs say so up front: on the CLI, **Janus is a policy monitor over a session it does not own, backstopped by `permissions.deny` — not a reachability lockdown.** The human constructs the session, so the SDK path's `tools=[]`/`strict_mcp_config`/`allowed_tools` layers are simply gone.

Wire the `janus-hook` shim into a settings file:
The fastest way in is `janus init` — it asks a few questions (what to protect, network and
git posture, how strict), writes the policy, the hook wiring, and the `permissions.deny`
backstop, shows you the settings diff before touching anything, and then verifies the
result through the real decision path:

```bash
pip install janus-guard
janus init # --dry-run to preview, --yes for CI
```

Or wire the `janus-hook` shim into a settings file yourself:

```json
{
"hooks": {
"PreToolUse": [
{ "hooks": [{ "type": "command",
"command": "janus-hook pre --policy /etc/janus/policy.json --mode gate" }] }
"command": "janus-hook pre --policy /etc/janus/policy.json --mode gate",
"timeout": 10 }] }
]
}
}
```

Keep the hook's `timeout` above the shim's `--deadline` (default 5s): the CLI's own hook
timeout fails **open**, so the shim must reach its deadline first and deny while it can.

`--mode gate` (default) enforces the tools the policy has an opinion about and abstains to the CLI's own permission flow elsewhere; `--mode policy` is strict default-deny. Gate mode auto-promotes to policy mode under `bypassPermissions`, where abstention would be a silent allow — so bypass sessions need the policy to enumerate their tool surface. The shim fails **closed** (unreadable policy, internal error, or its own `--deadline` all deny), which matters because the CLI's hook dispatch fails **open** on timeout. `janus-hook doctor` self-tests the install; `janus-hook backstop` prints the `permissions.deny` block that holds even if hooks stop running.

Phase 1 is deliberately stateless — static policy per call, no taint or cross-call state (the phase-2 daemon restores those). See the [adapters guide](https://agentic-ai-risk-mitigation.github.io/Janus/adapters/) for the full security model, gate/policy semantics, and the verified `ask`/`escalate` probe results.
Expand Down Expand Up @@ -883,7 +897,12 @@ janus/
│ └── claude_code.py # Claude Code CLI hook adapter (interactive `claude`)
└── cli/
└── hook.py # `janus-hook` — the CLI hook shim (fails closed)
├── hook.py # `janus-hook` — the CLI hook shim (fails closed)
├── main.py # `janus` — operator commands (init, doctor)
├── init.py # the `janus init` onboarding wizard
├── starter_policy.py # starter-policy builder + Claude Code tool table
├── claude_settings.py # settings.json read/merge/backup/write
└── _console.py # stdlib prompts (no TUI dependency)

examples/ # Demo scenario framework + FastAPI web app + docker-compose.yml for SpiceDB
tests/ # Offline regression suite (+ tests/smoke/, opt-in live SDK checks)
Expand Down
8 changes: 7 additions & 1 deletion docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ doc.

### Wiring it (phase 1: settings file, stateless)

`janus init` does all of the below interactively — policy, hook entry, and backstop — and
verifies the result through this same decision path; see
[Claude Code Deployment → Wizard setup](claude-code-deployment.md#wizard-setup-janus-init).
By hand:

```bash
janus-hook backstop > /tmp/backstop.json # the permissions.deny block; merge into settings
```
Expand All @@ -285,7 +290,8 @@ janus-hook backstop > /tmp/backstop.json # the permissions.deny block; merge i
"hooks": {
"PreToolUse": [
{ "hooks": [{ "type": "command",
"command": "janus-hook pre --policy /etc/janus/policy.json --mode gate" }] }
"command": "janus-hook pre --policy /etc/janus/policy.json --mode gate",
"timeout": 10 }] }
]
}
}
Expand Down
50 changes: 50 additions & 0 deletions docs/claude-code-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,56 @@ Three tiers, three guarantees:
The plugin and managed tiers ship in later phases; the design and verified probe results
live in `plans/claude-code-plugin-design.md` in the repository.

## Wizard setup (`janus init`)

`janus init` builds the tier-1 deployment — policy, `PreToolUse` hook, and the
`permissions.deny` backstop — from a short interactive questionnaire. It is a convenience
over the manual steps in [Getting Started](getting-started.md), not a different security
posture: what it writes is a settings-file hook deployment, with tier 1's guarantees and
tier 1's limits.

What it touches, and nothing else:

| Path | Contents |
|---|---|
| `.claude/janus/policy.json` | the policy, built from the starter plus your answers |
| `.claude/settings*.json` | the `PreToolUse` entry (with an explicit `timeout`) and the merged `permissions.deny` |
| `.claude/janus/config.json` | only when you name MCP servers — the `known_servers` sidecar |

Operational notes:

- **Scope** is the first question: `.claude/settings.json` (shared with the team),
`.claude/settings.local.json` (just you), or `~/.claude/settings.json` (every project).
On Windows the project default is the `.local` file, because the hook command must carry
absolute paths there and a shared file would be machine-specific.
- **Re-running is idempotent.** A Janus hook is recognized by its command, so a second run
updates the entry rather than appending one; duplicate entries from hand-editing collapse
to one. Foreign hooks and their order are never touched.
- **`permissions.deny` merges additively.** Entries you added by hand survive. Relaxing an
answer (allowing `git push`, opening the network) never silently removes a deny — the
wizard asks first.
- **The previous settings file is backed up** to `settings.json.bak-<timestamp>` before
every write, and the new file lands via an atomic replace.
- **Verification runs the deployed path**, feeding synthetic `PreToolUse` payloads through
`handle_cli_payload` with the exact flags it just wrote. A failing probe exits non-zero
with the files still written, so you can inspect the policy.
- **`--yes` is for CI.** Without it, a non-TTY stdin is refused rather than silently
accepting defaults nobody chose.
- **PATH matters.** Claude runs hooks through its own shell; if `janus-hook` is not
resolvable there the hook fails *open*. The wizard warns when the console script is not
on PATH and falls back to a `python -m janus.cli.hook` command pinned to the interpreter
that has Janus installed. This is one more reason the backstop is not optional.
- **Path patterns match either separator.** Claude Code reports `file_path` using the
host's native separator (`C:\Users\...\.env` on Windows, verified on CLI 2.1.246), so
every path rule the wizard writes uses a `[/\\]` class. A `/`-only pattern silently
matches nothing on Windows — if you hand-edit the policy, keep the class.

The same caveat as every tier-1 deployment applies, and the wizard concentrates it: the
file it writes is a file the guarded agent can also write. The starter policy denies
`Write`/`Edit` of `.claude/settings*.json` and the Janus directory, but `Bash` can route
around that, and an agent that can run commands can run `janus init` itself. Treat tier 1
as a policy monitor; move to the plugin tier when you need the session to stay guarded.

## Why managed settings must use the force-enabled-plugin path

The "obvious" enterprise design — declare the hooks inline in managed settings, skip the
Expand Down
31 changes: 30 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,29 @@ Scenarios and the demo framework live under `examples/`. The current catalog inc
The `janus-hook` shim enforces a Janus policy on the interactive `claude` CLI via its
`PreToolUse` hook. It ships with the core install — no extra needed.

### The guided route

```bash
pip install janus-guard
janus init # asks a few questions, writes everything, verifies it
```

`janus init` asks where to guard (this project or the whole machine), what the agent must
never touch, how much network egress it gets, and how strict to be. Every question has a
recommended default, so pressing Enter throughout produces the starter setup below. It
then shows the **exact** settings-file diff and asks before writing anything, backs up any
existing settings file, and finishes by running its decisions through the real hook path —
`curl … | sh` denied, `.env` denied, ordinary reads allowed — so a `PASS` means that policy
denied that call, not that the wizard intended to.

Re-running it updates the existing hook in place rather than adding a second one. Useful
flags: `--dry-run` (show everything, write nothing), `--yes` (accept every default,
for CI), `--scope project|project-local|user`, `--force` (overwrite an existing policy).

### Doing it by hand

The wizard automates exactly these four steps; do them yourself if you would rather.

1. **Self-test the install**:

```bash
Expand Down Expand Up @@ -153,12 +176,18 @@ The `janus-hook` shim enforces a Janus policy on the interactive `claude` CLI vi
"hooks": {
"PreToolUse": [
{"hooks": [{"type": "command",
"command": "janus-hook pre --policy ~/.claude/janus/policy.json --mode gate"}]}
"command": "janus-hook pre --policy ~/.claude/janus/policy.json --mode gate",
"timeout": 10}]}
]
}
}
```

Set the `timeout` explicitly and keep it above the shim's `--deadline` (default 5s).
The CLI's own hook timeout fails **open** — a deny that arrives after it is discarded
and the tool runs — so the shim has to reach its deadline first and deny while it
still can.

4. **Add the backstop** — `janus-hook backstop` prints a `permissions.deny` block to merge
into the same settings file. It is the only layer that holds if hooks silently stop
running.
Expand Down
21 changes: 17 additions & 4 deletions examples/claude_code/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# Starter policy for the Claude Code CLI

`policy.starter.json` is a ready-to-copy gate-mode policy for guarding an interactive
`claude` session with `janus-hook`. Wiring instructions:
`claude` session with `janus-hook`. To have it written and wired for you — customized by a
few questions and verified afterwards — run `janus init` instead; it builds this same
policy from `janus.cli.starter_policy.build_starter_policy()`, and a test pins the two
together so the file you copy and the file the wizard writes cannot drift. Wiring
instructions:
[Getting Started → Guard Your Interactive Claude Code](../../docs/getting-started.md);
security model and flag reference: `docs/adapters.md`; choosing a delivery vehicle:
`docs/claude-code-deployment.md`.
Expand Down Expand Up @@ -34,9 +38,18 @@ name — the `mcp__<server>__` prefix is stripped) with an allow rule, and set
`known_servers` in the `--config` sidecar so a rogue server can't inherit the rule.

**Regex conditions are searches, not full matches.** JSON Schema `pattern` matches anywhere
in the string (Python `re.search`), so anchor deliberately: `(^|/)\.env` rather than
`\.env` (which would also hit `.environment`), `\.pem$` rather than `\.pem`. Negative
lookahead works — `\.env(?!\.example)` is how the starter exempts `.env.example`.
in the string (Python `re.search`), so anchor deliberately: `(^|[/\\])\.env` rather than a
bare `\.env`, `\.pem$` rather than `\.pem`. Negative lookahead works —
`\.env(?!\.example)` is how the starter exempts `.env.example`. Anchoring bounds where a
match may *start*; it does not make the match exact, so the starter's `.env` rule also
covers `.environment` — the safe direction to be wrong in.

**Match both path separators.** Claude Code reports `file_path` with the *host's* separator
— verified on Windows (CLI 2.1.246), which sends `C:\Users\...\.env`. A pattern anchored on
`/` alone matches nothing there, so use a class: `[/\\]` for a separator and `[^/\\]` for a
"rest of the segment" tail. This is not hypothetical: an earlier version of this starter was
`/`-only, and on Windows it allowed every `.env`, `~/.ssh`, `~/.aws/credentials` and
`~/.claude/.credentials.json` read, plus writes to `.claude/settings.json`.

**Deny conditions fail closed on absent arguments; allow conditions fail strict.** A deny
rule conditioning an argument the call omits *matches vacuously*; an allow rule
Expand Down
10 changes: 5 additions & 5 deletions examples/claude_code/policy.starter.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"conditions": {
"file_path": {
"type": "string",
"pattern": "(^|/)\\.env(?!\\.example)[^/]*$|/\\.ssh/|/\\.aws/credentials|\\.pem$|/\\.claude/\\.credentials\\.json$"
"pattern": "(^|[/\\\\])\\.env(?!\\.example)[^/\\\\]*$|[/\\\\]\\.ssh[/\\\\]|[/\\\\]\\.aws[/\\\\]credentials|\\.pem$|[/\\\\]\\.claude[/\\\\]\\.credentials\\.json$"
}
},
"fallback": 0
Expand All @@ -25,7 +25,7 @@
"conditions": {
"command": {
"type": "string",
"pattern": "(curl|wget)[^|;&]*\\|\\s*(ba|z|fi)?sh\\b|/\\.ssh/id_|/\\.aws/credentials|/\\.claude/\\.credentials"
"pattern": "(curl|wget)[^|;&]*\\|\\s*(ba|z|fi)?sh\\b|[/\\\\]\\.ssh[/\\\\]id_|[/\\\\]\\.aws[/\\\\]credentials|[/\\\\]\\.claude[/\\\\]\\.credentials"
}
},
"fallback": 0
Expand All @@ -44,7 +44,7 @@
"conditions": {
"file_path": {
"type": "string",
"pattern": "/\\.claude/settings(\\.local)?\\.json$|/\\.claude/janus/"
"pattern": "[/\\\\]\\.claude[/\\\\]settings(\\.local)?\\.json$|[/\\\\]\\.claude[/\\\\]janus[/\\\\]"
}
},
"fallback": 0
Expand All @@ -63,7 +63,7 @@
"conditions": {
"file_path": {
"type": "string",
"pattern": "/\\.claude/settings(\\.local)?\\.json$|/\\.claude/janus/"
"pattern": "[/\\\\]\\.claude[/\\\\]settings(\\.local)?\\.json$|[/\\\\]\\.claude[/\\\\]janus[/\\\\]"
}
},
"fallback": 0
Expand All @@ -82,7 +82,7 @@
"conditions": {
"file_path": {
"type": "string",
"pattern": "/\\.claude/settings(\\.local)?\\.json$|/\\.claude/janus/"
"pattern": "[/\\\\]\\.claude[/\\\\]settings(\\.local)?\\.json$|[/\\\\]\\.claude[/\\\\]janus[/\\\\]"
}
},
"fallback": 0
Expand Down
Loading