Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup uv
uses: astral-sh/setup-uv@v8
uses: astral-sh/setup-uv@v8.3.2
with:
version: "latest"
- name: Install MkDocs and theme
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup uv
uses: astral-sh/setup-uv@v8
uses: astral-sh/setup-uv@v8.3.2
with:
version: "latest"
python-version: ${{ matrix.python-version }}
Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,36 @@ This project follows [Semantic Versioning](https://semver.org/).

### Added

- **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
here, so this is a policy monitor backstopped by `permissions.deny` (`janus-hook backstop`
prints the block), not a reachability lockdown — `docs/adapters.md` spells out the weaker
security model. `mode="gate"` (default) enforces only the tools the policy has an opinion
about and abstains to the CLI permission flow elsewhere; `mode="policy"` is strict
default-deny; gate mode auto-promotes to policy mode under `bypassPermissions`, where
abstention would degrade to a silent allow. The shim owns its exit path so enforcement fails
*closed* (unreadable policy, internal error, or its own `--deadline` all deny) even though
the CLI's hook dispatch fails *open* — a hook that overran the CLI's `timeout` had its deny
discarded on 2.1.233. Taint-gate escalation emits the CLI's `ask` decision (verified to
block and surface the reason; `escalate` is unrecognized and would silently allow).
Phase 1 is deliberately stateless — static policy evaluation per call, no taint, no
provenance, no cross-call state; the daemon that restores those is phase 2
(`plans/claude-code-plugin-design.md`).
- `tests/test_claude_code_adapter.py` + `tests/test_claude_code_shim.py` (81 offline tests)
covering payload normalization, gate/policy semantics, unsupervised promotion, escalation
downgrade, and the shim's fail-closed paths.
- **`on_decision` audit callback in the Claude Agent SDK adapter** — `janus_options()`,
`janus_hooks()`, and `janus_pretooluse_hook()` accept an optional
`on_decision(runtime_tool_name, arguments, allowed, reason)` callable, invoked once per
PreToolUse evaluation (passthrough tools and the fail-closed internal-error path included;
`reason` is `None` on allow). Gives downstream consumers a programmatic seam to audit
hook-level policy denies, which previously surfaced only in Python logging. Strictly
observational: callback exceptions are logged and swallowed and can never change an
enforcement outcome. When a `Session` is wired, denies are additionally recorded as
`{"kind": "policy_deny", ...}` session notes, giving `session.events` symmetry with the
taint `gate_deny` events. Version bumped to 0.1.1 so consumers can feature-detect
`on_decision` from `janus.__version__`.
- **Prompt-borne untrusted input** — `Session.mark_untrusted(text, label=, extract=,
normalize=)`: the one-line, audited way to declare pasted content (an inbound email, a
scraped page) untrusted at the call site that already knows it. Taints the session exactly
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Don't conflate them — they are independent:

`janus/adapters/claude_agent_sdk.py` is different in kind: the Claude Agent SDK's tool loop runs inside the `claude` CLI subprocess, so Janus never sees the call in-process and must enforce at the SDK's pre-execution seams. Use `janus_options()` — it builds a locked-down `ClaudeAgentOptions` so that a silently skipped `PreToolUse` hook (which has regressed upstream before) can't escalate to arbitrary `Bash`. Full seam-by-seam reference, including the layering rationale and every knob, is in **`docs/adapters.md`**; verified SDK behaviour is in `plans/claude-agent-sdk-hardening.md`. Behind the `claude` extra.

`janus/adapters/claude_code.py` + `janus/cli/hook.py` (the `janus-hook` console script, core install) target the *interactive* CLI via its `PreToolUse`/`PostToolUse` hooks. Weaker model than the SDK path — a policy monitor backstopped by `permissions.deny`, not a reachability lockdown — and phase 1 is deliberately stateless (static policy per call; no taint or cross-call state until the phase-2 daemon). Gate mode abstains on unlisted tools but auto-promotes to strict default-deny under `bypassPermissions`. The shim fails closed even though CLI hook dispatch fails open on timeout. Reference: the CLI section of `docs/adapters.md`; design and verified CLI probe results: `plans/claude-code-plugin-design.md`.

## Conventions

- **Style**: 4-space indent, type hints on public interfaces, concise docstrings where behavior is non-obvious. `snake_case` modules/functions, `PascalCase` classes, `UPPER_CASE` constants. Ruff config (line length, target version, rule set) lives in `pyproject.toml` — read it there rather than assuming.
Expand All @@ -95,3 +97,4 @@ Always question and analyze the intent and purpose of the code against its funct
- Hardcoded SpiceDB token defaults in `pde_enforcer.py` and `janus/policy/pde/`
- PDE taint remains manual and session-scalar; `TaintTracker` supersedes it for new work but the two are not yet unified
- `TaintTracker` is only wired into the Claude Agent SDK adapter — LangChain/ADK adapters have no post-execution seam yet
- The Claude Code CLI adapter is phase-1 stateless: `janus-hook` imports Janus per call and holds no cross-call state, so no taint/provenance on that path until the phase-2 daemon
61 changes: 47 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Janus intercepts every tool call an LLM agent makes and validates it against a s
- [Automatic taint — the `PostToolUse` seam](#automatic-taint--the-posttooluse-seam)
- [Alternative seam — `can_use_tool` callback](#alternative-seam--can_use_tool-callback)
- [Belt-and-braces — `guard_tool_body()`](#belt-and-braces--guard_tool_body)
- [Claude Code CLI (interactive `claude`)](#claude-code-cli-interactive-claude)
- [Standalone Policy Enforcement](#standalone-policy-enforcement)
- [Runtime Policy Management](#runtime-policy-management)
- [Error Handling](#error-handling)
Expand All @@ -72,7 +73,7 @@ Janus intercepts every tool call an LLM agent makes and validates it against a s
- **Built-in tools** — ready-to-use file system and command execution tools with workspace sandboxing
- **Custom tools** — define your own tools with `ToolDef` / `ToolParam`; Janus guards them automatically
- **10+ LLM providers** — OpenAI, Anthropic, Google Gemini, Azure OpenAI, AWS Bedrock, Ollama, vLLM, Together AI, OpenRouter
- **Framework adapters** — plug Janus enforcement into LangChain, Google ADK, and Claude Agent SDK (Claude Code) agents
- **Framework adapters** — plug Janus enforcement into LangChain, Google ADK, and Claude Agent SDK (Claude Code) agents, plus a `PreToolUse` hook shim (`janus-hook`) for the interactive Claude Code CLI
- **Standalone enforcer** — use `PolicyEnforcer` independently in any agentic framework
- **Three fallback actions** — raise `PolicyViolation`, call `sys.exit`, or prompt the user interactively
- **Workspace isolation** — file tools are scoped to a directory; path-traversal attempts are rejected
Expand Down Expand Up @@ -107,6 +108,8 @@ uv add "janus-guard[claude]" # Claude Agent SDK (Claude Code) adapter
uv add "janus-guard[all]" # Everything
```

The Claude Code **CLI** adapter (`janus.adapters.claude_code` + the `janus-hook` shim) needs no extra — it ships with the core install.

**For development:**

```bash
Expand Down Expand Up @@ -303,9 +306,10 @@ if reason:
- Gating is whole-tool and argument-independent by design — the arguments are exactly what an
injected instruction controls.
- `classify=` adds content-aware labels on top of the static source map.
- With the Claude Agent SDK adapter, derivation is **automatic**: pass `taint=tracker` to
`janus_hooks()` / `janus_options()` and the `PostToolUse` seam records reads while `PreToolUse`
gates sinks.
- With the Claude Agent SDK adapter, derivation is **automatic**: pass
`session=Session(taint=tracker)` to `janus_hooks()` / `janus_options()` and the `PostToolUse`
seam records reads while `PreToolUse` gates sinks. (`taint=tracker` still works but is
deprecated; `Session` adds provenance and the audit trail.)

Full reference: [Taint Tracking](https://agentic-ai-risk-mitigation.github.io/Janus/taint/). This
is distinct from the PDE engine's manual session-scalar taint, which needs SpiceDB.
Expand Down Expand Up @@ -642,12 +646,13 @@ The hook seam alone leaves tool-level reachability hostage to the hook firing, a
```python
from claude_agent_sdk import create_sdk_mcp_server
from janus.adapters.claude_agent_sdk import janus_options
from janus.policy import Session

options = janus_options(
TOOL_POLICY,
mcp_servers={"research": create_sdk_mcp_server(name="research", tools=[...])},
required_args={"fetch_page": ["url"]},
taint=tracker, # optional: automatic per-source taint gating
session=Session(taint=tracker), # optional: automatic taint gating + provenance
hook_approved_tools={"send_email"}, # optional: sinks must clear hook *and* permission layer
output_format={"type": "json_schema", "schema": SCHEMA}, # extra kwargs forwarded
)
Expand Down Expand Up @@ -693,17 +698,18 @@ Unexpected exceptions inside the hook (enforcer bug, malformed input) return a *

#### Automatic taint — the `PostToolUse` seam

Pass `taint=` a [`TaintTracker`](#taint-tracking-ipi-defence) and both seams are wired: `PostToolUse` derives session taint from tool outputs, and `PreToolUse` gates sinks on it before the static policy runs. No manual `update_taint()` calls:
Pass `session=` a `Session` wrapping a [`TaintTracker`](#taint-tracking-ipi-defence) and both seams are wired: `PostToolUse` derives session taint from tool outputs, and `PreToolUse` gates sinks on it before the static policy runs. No manual `update_taint()` calls:

```python
from janus.policy import TaintTracker
from janus.policy import Session, TaintTracker
from janus.adapters.claude_agent_sdk import janus_hooks

tracker = TaintTracker(sources={"fetch_page": "web"}, gates={"send_email": "*"})
options = ClaudeAgentOptions(..., hooks=janus_hooks(TOOL_POLICY, taint=tracker))
options = ClaudeAgentOptions(..., hooks=janus_hooks(TOOL_POLICY, session=Session(taint=tracker)))
```

Use one tracker per session; blocked calls never taint it.
Use one session per agent conversation; blocked calls never taint it. (Passing a bare
`taint=tracker` still works but is deprecated — `Session` adds provenance and the audit trail.)

#### Alternative seam — `can_use_tool` callback

Expand All @@ -720,6 +726,29 @@ guarded = guard_tool_body("fetch_page", my_async_body, TOOL_POLICY,
required_args={"fetch_page": ["url"]})
```

### Claude Code CLI (interactive `claude`)

`janus.adapters.claude_code` targets the **interactive CLI** — the `claude` you type into — via its `PreToolUse`/`PostToolUse` hooks. It is a core install (no extra): a hook has to run wherever `claude` runs.

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:

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

`--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.

---

## Standalone Policy Enforcement
Expand Down Expand Up @@ -846,11 +875,15 @@ janus/
│ ├── file_tools.py # read_file, write_file, edit_file, list_directory
│ └── command_tools.py # run_command, fetch_url
└── adapters/
├── _base.py # Shared adapter utilities
├── langchain.py # LangChain integration
├── adk.py # Google ADK (Gemini) integration
└── claude_agent_sdk.py # Claude Agent SDK (Claude Code) integration
├── adapters/
│ ├── _base.py # Shared adapter utilities
│ ├── langchain.py # LangChain integration
│ ├── adk.py # Google ADK (Gemini) integration
│ ├── claude_agent_sdk.py # Claude Agent SDK (Claude Code) integration
│ └── claude_code.py # Claude Code CLI hook adapter (interactive `claude`)
└── cli/
└── hook.py # `janus-hook` — the CLI hook shim (fails closed)

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
Loading