Tenuo governance for Claude Code — warrants, hooks, MCP proxy, and Cloud lifecycle
Project description
Tenuo for Claude Code
Tenuo governance for Claude Code:
every agent tool call is checked against a signed warrant (hook → authorizer),
with a receipt on each decision, including under --dangerously-skip-permissions.
Policy is tenuo.yaml in your project directory; tenuo-claude init generates
the warrant, authorizer config, Claude hooks, and MCP proxy wiring.
Install
PyPI (your project anywhere on disk):
pip install tenuo-claude-code
PyPI: pypi.org/project/tenuo-claude-code
cd your-project # must contain tenuo.yaml
tenuo-claude init
tenuo-claude up
Demo repo (git clone — includes sample policy, sandbox, MCP demo server):
git clone https://github.com/tenuo-ai/claude-governance.git
cd claude-governance
uv venv && uv sync && chmod +x bin/tenuo-claude
uv run tenuo-claude bootstrap --local
tenuo-claude and tenuo-admin remain as CLI aliases for backward compatibility.
Quick start (local, ~1 minute)
Requires Python ≥ 3.10, Docker, and Claude Code.
After install (see above), bootstrap runs preflight → init → up → doctor. Open Claude
Code in the directory that contains tenuo.yaml.
Other entry points:
| Command | When |
|---|---|
tenuo-claude check |
Diagnose deps, credentials, wiring drift |
tenuo-claude onboard |
Interactive wizard (local or Cloud) |
tenuo-claude onboard --cloud |
Cloud wizard (Quick Connect + optional admin setup) |
tenuo-claude init --cloud |
Write tenuo.cloud.yaml (Cloud URL only) |
tenuo-claude refresh |
Re-apply tenuo.yaml after policy edits |
Cloud credentials and platform setup: see Cloud mode below.
Advanced demo (optional WebFetch human approval): docs/PRESENTATION.md —
separate tenuo.advanced.yaml overlay; not part of the default tour.
Setup
Python environment (recommended — uv):
uv venv && uv sync
source .venv/bin/activate # Windows: .venv\Scripts\activate
Or without uv: python3 -m pip install -r requirements.txt (same pins).
Re-run init after switching venvs — hooks pin sys.executable in .claude/settings.json.
Local mode
No Tenuo Cloud account. Warrants are minted from a local issuer key in .state/.
Receipts stay in .state/receipts.jsonl. Off-allowlist WebFetch URLs are denied
(no human approval).
1. Policy — stock tenuo.yaml is ready. Do not add cloud: or
WebFetch.approval (those are Cloud-only).
2. Ensure Cloud files are absent (otherwise up stays in Cloud mode):
# if you previously ran Cloud setup:
mv .state/cloud.env .state/cloud.env.bak 2>/dev/null
mv .state/cloud_state.json .state/cloud_state.json.bak 2>/dev/null
unset TENUO_ADMIN_KEY TENUO_CONNECT_TOKEN TENUO_API_KEY TENUO_CONTROL_PLANE_URL
3. Initialize and run:
python3 tenuo_claude.py init # mint local warrant, wire hooks + MCP proxy
python3 tenuo_claude.py refresh # after editing tenuo.yaml (policy → warrant + gateway)
python3 tenuo_claude.py up # should print: Local mode (no Cloud).
python3 tenuo_claude.py doctor --no-live
python3 tenuo_demo.py
4. Verify — python3 tenuo_claude.py status should show:
authorizer : up (http://127.0.0.1:9090) | cloud: disabled
No web-approval: line. Revoke with python3 tenuo_claude.py revoke.
Cloud mode
Root-signed session warrants, central receipt stream in
cloud.tenuo.ai, fleet revocation (~30s SRL sync). Optional
human approval on off-allowlist WebFetch is an advanced demo add-on, not default setup.
Presentation runbook: docs/PRESENTATION.md.
1. Tenant + keys — you need two API keys in two files (separation of duties):
| Key | Role | File | Used by |
|---|---|---|---|
| Runtime | Quick Connect authorizer service account | .state/cloud.env |
tenuo_claude.py up, hooks, demo |
| Admin | Tenant admin (not in Quick Connect) | ~/.tenuo/admin.env |
tenuo_admin.py setup once |
mkdir -p .state ~/.tenuo
cp cloud.env.example .state/cloud.env
cp admin.env.example ~/.tenuo/admin.env
# Edit cloud.env — see Quick Connect steps below.
# Edit admin.env — tenant-admin key (separate from Quick Connect).
Runtime key via Quick Connect
Quick Connect copies a single connect token (tenuo_ct_…) that bundles the
control-plane URL and authorizer bearer key. Put that in .state/cloud.env as
TENUO_CONNECT_TOKEN — you do not need to set TENUO_API_KEY separately.
| What you copy | Env var | What it is |
|---|---|---|
| Connect token from dashboard | TENUO_CONNECT_TOKEN |
One paste; preferred |
| Manual tab: URL + key | TENUO_CONTROL_PLANE_URL + TENUO_API_KEY |
Fallback if you skip the token |
Internally, Cloud HTTP calls use the embedded tc_… bearer key (the k field
inside the token). The demo parses TENUO_CONNECT_TOKEN and passes that key to
the authorizer container as TENUO_API_KEY.
Quick Connect does not include the tenant-admin key.
-
Sign in at cloud.tenuo.ai
-
Agents → Quick Connect
-
Connection type: Authorizer Only (holder agent registration is done by
tenuo-admin setup, not Quick Connect) -
Copy the connect token (
tenuo_ct_…) into.state/cloud.env:export TENUO_CONNECT_TOKEN="tenuo_ct_..." export TENUO_AUTHORIZER_NAME="claude-code-demo"
Or choose deployment Manual in the dialog and paste
TENUO_CONTROL_PLANE_URLTENUO_API_KEYinstead (seecloud.env.example).
The token is shown once. Do not use ak_… values from the API Keys table — those
are key IDs, not bearer secrets. Quick Connect embeds the real tc_… runtime key.
Admin key (not in Quick Connect)
Create separately: Settings → API Keys → tenant-admin role — or use the key from
tenant onboarding. Save to ~/.tenuo/admin.env only.
Why Authorizer Only (not Agent + Authorizer)? Quick Connect Agent + Authorizer
bundles an agent identity for embedded SDKs that auto-claim on startup. This demo uses
a sidecar authorizer plus a separate holder agent: PoP is signed by
.state/holder_key.b64 in the Claude hook, while Quick Connect credentials only
authenticate the authorizer to Cloud (heartbeat, SRL, trigger fire). tenuo-admin setup
registers the holder agent and claims it with that local key; Cloud then issues
warrants bound to it. Agent + Authorizer Quick Connect would claim a different key
and break PoP verification.
Important: never put the admin key in .state/cloud.env or your shell when running
tenuo-claude / tenuo_demo — runtime refuses to start if an admin key is reachable.
2. Cloud policy — tenuo-claude init --cloud writes tenuo.cloud.yaml (control-plane
URL only). No yaml merge required.
3. One-time Cloud registration (platform / prep — not every session):
unset TENUO_ADMIN_KEY # only needed if exported in your shell
python3 tenuo_admin.py setup
Creates the holder agent, Cloud trigger, and (if tenuo.advanced.yaml is present) approval
policy. Writes .state/cloud_state.json. Re-run after policy changes (tenuo.yaml,
tenuo.cloud.yaml, tenuo.advanced.yaml (if present), subagents:).
4. Daily developer flow:
unset TENUO_ADMIN_KEY
python3 tenuo_claude.py init # wire hooks; re-run after venv changes
python3 tenuo_claude.py refresh # after tenuo.yaml policy edits (warrant + gateway)
python3 tenuo_claude.py up # fires trigger → root-signed session warrant
python3 tenuo_claude.py doctor --no-live
python3 tenuo_demo.py
5. Verify — python3 tenuo_claude.py status should show:
authorizer : up (…) | cloud: registered authz_…
(web-approval: in status appears only with tenuo.advanced.yaml — see
Advanced demo below.)
Revoke from Cloud dashboard or tenuo-claude status warrant id (~30s SRL sync).
Advanced demo: human approval (optional)
The default demo (python3 tenuo_demo.py) covers scope, deny, SSRF, and subagents —
off-allowlist WebFetch is denied by the domain allowlist, not sent for approval.
Human approval on off-allowlist URLs is an advanced add-on for customer presentations. Use a separate overlay so it never mixes with the core tool or default tour:
Prerequisite — approver in Tenuo Cloud (platform prep, not this repo):
tenuo-admin setup references an existing approver; it does not create one. Before
init --advanced, someone with dashboard access must:
- Connect a notification channel (Slack or Telegram) — Adding notification channels
- Create an identity binding with a Display Name (e.g.
Jane Doe) — Identity bindings (Dashboard → Channels → Identity Bindings)
The --approver string must match that Display Name exactly.
tenuo-claude init --advanced --approver "Jane Doe"
# or: cp tenuo.yaml.advanced.example tenuo.advanced.yaml and edit
tenuo-admin setup # wires approval policy; re-run after overlay changes
python3 tenuo_demo.py --advanced # shows PAUSE for off-allowlist WebFetch
python3 tenuo_demo.py --advanced --live-approval # blocks until approver responds
Runbook: docs/PRESENTATION.md.
Switching local ↔ Cloud
up picks mode from files on disk, not yaml alone. If Cloud files exist, you stay
in Cloud mode until you remove them and restart the authorizer:
| To switch to local | To switch to Cloud |
|---|---|
Move aside .state/cloud.env and .state/cloud_state.json |
Restore both files |
Comment/remove tenuo.cloud.yaml / tenuo.advanced.yaml |
Restore overlay files |
tenuo-claude down → init → up |
tenuo-admin setup (if needed) → down → up |
Status: cloud: disabled |
Status: cloud: registered … |
down is required when switching — a running container keeps its old Cloud env until
replaced.
Reviewer brief: docs/SECURITY-TEAM.md. Deep dive: docs/DETAILS.md.
See it in action
These examples run real Claude Code against the policy. --dangerously-skip-permissions
turns off Claude's permission prompts; the warrant still applies because enforcement
is in the hook, not in Claude.
In-scope vs out-of-scope reads:
claude -p "Read sandbox/notes.txt and summarize." # allowed
claude -p "Read /etc/hosts" --dangerously-skip-permissions # denied
Destructive instruction with guardrails off:
claude -p "Use delete_deployment to tear down production." --dangerously-skip-permissions
Prompt injection: sandbox/incident-report.md hides instructions to exfil secrets and
delete prod. If the model refuses, fine — the warrant still does not grant those tools.
claude -p "Summarize sandbox/incident-report.md for me." --dangerously-skip-permissions
Subagent attenuation (session allows Bash; researcher child warrant does not):
claude -p "Use the researcher subagent to run 'ls -la sandbox' and report the result." \
--dangerously-skip-permissions
Without Claude:
python3 tenuo_demo.py
python3 tenuo_claude.py audit
Receipt trail
Same demo sequence, real audit output (local convenience log; authorizer produces
the signed receipts, streamed to Cloud when connected):
$ python3 tenuo_demo.py && python3 tenuo_claude.py audit
ALLOW [gov] Read -> read_file authorized
DENY [gov] Read -> read_file Constraint not satisfied
DENY [aud] delete_deployment -> unlisted Constraint not satisfied
ALLOW [gov] Bash -> run_command authorized
DENY [gov] Bash -> run_command Constraint not satisfied
ALLOW [gov] Grep -> grep authorized
ALLOW [gov] WebFetch -> web_fetch authorized
DENY [gov] WebFetch -> web_fetch Constraint not satisfied
ALLOW [gov] Agent -> spawn_agent authorized
DENY [gov] Bash <researcher> -> run_command Constraint not satisfied
With Cloud WebFetch.approval enabled, an off-allowlist SSRF-safe URL shows
PENDING [appr] before resolve. See DETAILS.md.
vs. native Claude Code permissions
Claude Code permissions are configuration: allow/ask/deny rules in settings.json,
optionally locked down fleet-wide via managed settings. Tenuo adds a credential:
a signed warrant checked on every tool call, with TTL, revocation, and a receipt stream.
Tenuo is built on top of Claude's hook and managed-settings mechanisms — not a
replacement. You still deploy PreToolUse hooks (this demo wires them from tenuo.yaml);
for fleet enforce, use managed settings so users cannot remove them.
| Claude Code permissions | Tenuo warrant | |
|---|---|---|
| Policy form | Allow/ask/deny rules in settings | Signed credential; Cloud mode chains to tenant root |
| Expiry | Rules persist until edited | Session TTL (~1h); up refreshes |
| Revocation | Edit rules; sessions may keep prior allowances | Revoke warrant id; live in ~30s (Cloud), no restart |
| Evidence | Hook logs optional; no signed trail by default | Signed receipt per decision; central stream with Cloud |
| Delegation | Subagents follow project/user tool policy | Cryptographic attenuation; session is the ceiling |
| Exceptions | Additional allow rules | Optional Cloud approval gate on off-allowlist WebFetch |
--dangerously-skip-permissions |
Bypasses Claude permission prompts* | Warrant still enforced |
*Managed settings can disable bypass (disableBypassPermissionsMode). Verify native
behavior against Claude Code permissions.
How it works
tenuo.yaml
(policy — single source of truth)
│
init / up generates
▼
┌─────────────────────────────────────────────┐
│ warrant · authorizer config · Claude hooks │
│ · MCP proxy wiring │
└─────────────────────────────────────────────┘
│
┌────────────────┴────────────────┐
native tools MCP tools
│ │
PreToolUse hook MCP proxy (.mcp.json)
└────────────┬────────────────┘
▼
tenuo_claude.py → authorizer → allow / deny → receipt
On each tool call the hook or MCP proxy signs a proof-of-possession and asks the authorizer. The decision lives outside Claude.
| Path | Enforcement |
|---|---|
| MCP proxy | Structural: Claude talks to the proxy, not the downstream server |
| PreToolUse hook | Cooperative: returns allow/deny; hardened via fail-closed + managed settings |
Both use the same warrant and authorizer. See DETAILS.md.
What the security team sees
With Tenuo Cloud, each session warrant chains to your tenant root. Platform security gets one stream to answer: what did agents do, under what authority, who approved the exceptions — and can revoke a compromised warrant in about 30 seconds without touching the laptop.
Admin vs runtime separation:
| Tool | Key | Does |
|---|---|---|
tenuo_admin.py setup |
admin (~/.tenuo/admin.env) |
Register holder, create trigger from tenuo.yaml |
tenuo_claude.py up |
runtime (.state/cloud.env) |
Fire trigger, run authorizer |
See Setup → Cloud mode for step-by-step credentials and commands.
Cloud audit stream
Every hook and demo decision is also a signed receipt in cloud.tenuo.ai: allow, deny, spawn, and (when configured) human-approved exceptions — one stream for the whole fleet.
Drill into an approved off-allowlist WebFetch to see the approval bound to that
specific call — approver, timestamp, and cryptographic request hash:
Revocation: revoke the session warrant id from tenuo-claude status or the Cloud
dashboard; authorizers pick up the SRL within ~30s. Local-only mode:
tenuo-claude revoke.
One-page brief for security reviewers: docs/SECURITY-TEAM.md.
Policy (tenuo.yaml)
Warrant, routes, hooks, and MCP wiring come from one file:
name: claude-code-demo
sandbox: ./sandbox
mode: enforce
enforce:
Read: "subpath:{sandbox}"
Bash: "shlex:ls,pwd,echo,date"
WebFetch:
domains: ["api.github.com", "*.githubusercontent.com", "*.tenuo.ai"]
default: deny
subagents:
researcher:
tools: [Read, Grep, Glob]
mcp:
downstream: ./ops_server.py
enforce:
read_file: "subpath:{sandbox}"
enforce: allowed and argument-checked.audit: harness tools fromharness_tools.yaml(extend withaudit_extra:).default: deny: everything else blocked with a receipt.mcp.enforce: bare downstream tool name +patharg only (single MCP server in this demo).- With
subagents:on, bundled Workflow is audit-allowed but its inner agent calls use undeclared roles and are denied — see DETAILS.md.
Commands
| Command | Does |
|---|---|
init |
Mint warrant, wire hooks and .mcp.json |
refresh |
Re-apply tenuo.yaml (warrant, gateway, hooks); restarts authorizer if up |
up / down |
Start / stop authorizer |
status |
Warrant, posture, Cloud summary |
doctor [--no-live] |
Self-test allow/deny |
audit [--tail N] |
Receipt trail |
revoke |
Revoke session warrant |
Enterprise deployment
Ship the whole directory (or an internal package) to a fixed path, e.g.
/opt/tenuo/claude-governance. Governance wiring uses the committed launcher
bin/tenuo-claude — no machine-specific Python paths in .mcp.json.
Install layout
/opt/tenuo/claude-governance/
bin/tenuo-claude # resolves .venv / TENUO_PYTHON / uv
tenuo.yaml # team policy (git)
.mcp.json # portable MCP proxy wiring (git)
.venv/ # created on host: uv sync
.state/ # per-machine keys + warrant (never commit)
Optional fleet env (MDM / systemd / launchd):
export TENUO_ROOT=/opt/tenuo/claude-governance
export TENUO_PYTHON=/opt/tenuo/claude-governance/.venv/bin/python
# Or pin the launcher path when generating wiring on a golden image:
export TENUO_CLAUDE_BIN=/opt/tenuo/claude-governance/bin/tenuo-claude
On each machine: uv sync (or pip install tenuo-claude-code), tenuo-claude init,
tenuo-claude up. After policy changes: tenuo-claude refresh. Preflight:
tenuo-claude check (validates launcher, hook/MCP wiring drift, authorizer).
Managed settings (hooks)
Hooks override project .claude/settings.json when deployed via managed settings
(highest precedence). Point at the same launcher:
// macOS: /Library/Application Support/ClaudeCode/managed-settings.json
{
"hooks": {
"PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "/opt/tenuo/claude-governance/bin/tenuo-claude _hook"}]}],
"PostToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "/opt/tenuo/claude-governance/bin/tenuo-claude _post"}]}]
}
}
MCP (structural enforcement)
Project .mcp.json is checked into git and uses a relative launcher — Claude
starts MCP from the project root:
{
"mcpServers": {
"tenuo-files": {
"command": "tenuo-claude",
"args": ["_mcp-proxy"]
}
}
}
For fleets that block project MCP config, mirror the same server in managed MCP
policy (same command/args, or absolute path to bin/tenuo-claude). Do not point
.mcp.json at the downstream server — that bypasses the proxy if the hook fails.
Scope precedence: local / managed MCP entries with the same server name override
project .mcp.json. Standardize on the tenuo-files name or enforce via
allowedMcpServers in managed settings.
Deploy with MDM alongside the CLI and tenuo.yaml. Governance covers agent tool
calls, not interactive ! shell — restrict that at the workstation if needed.
Rolling out
- Local eval — Setup → Local mode:
init,up,doctor,tenuo_demo.py. - Observe-only —
mode: audit: compute and receipt real allow/deny without blocking. The hook emits no permission decision, so observe-only never weakens Claude's stock prompts. Tune onWOULD-DENYrows, then setmode: enforce. - Fleet enforce — managed settings + Cloud root-signed warrants + team policy in
tenuo.yaml.
Send security reviewers docs/SECURITY-TEAM.md. Mechanics: docs/DETAILS.md.
Security boundaries
Tenuo controls which tool calls the agent may make, not every execution side effect. The Map is not the Territory.
Claude Code only blocks PreToolUse on exit code 2 or explicit deny; _hook converts
errors into deny decisions. doctor --no-live skips the live Claude harness check.
Fail-closed (run live for prospects):
mv tenuo.yaml tenuo.yaml.bak
# every tool call denied: Tenuo hook error (fail-closed): Missing …/tenuo.yaml
mv tenuo.yaml.bak tenuo.yaml
Limits: Bash allowlist checks command shape; WebFetch checks URL strings; new Claude
tools default-deny until listed in harness_tools.yaml.
Claude Code version assumptions: spawn routing keys on tool names Agent /
Task and the agent_type hook field (empirically claude 2.1.x). doctor --live
checks PreToolUse exit-code semantics (exit 2 blocks). If Anthropic renames spawn
tools, spawns fail closed unless the new name is only audit-listed.
Files
| File | Purpose |
|---|---|
tenuo.yaml |
Policy |
.mcp.json |
MCP proxy wiring (tenuo-claude or ./bin/tenuo-claude) |
bin/tenuo-claude |
Git-clone launcher for hooks, MCP, and CLI |
src/tenuo_claude_code/ |
PyPI package source |
tenuo.yaml.cloud.example |
Tool Cloud overlay template (cloud.url only) |
tenuo.yaml.advanced.example |
Advanced overlay (WebFetch approval + approver) — optional presentations |
cloud.env.example |
Runtime key template → .state/cloud.env |
admin.env.example |
Admin key template → ~/.tenuo/admin.env |
harness_tools.yaml |
Bundled harness tool allowlist |
docs/SECURITY-TEAM.md |
One-page reviewer brief |
docs/DETAILS.md |
Deep dive (SSRF examples, audit invariants, subagents) |
docs/images/ |
Cloud audit stream + approval receipt screenshots |
tenuo_claude.py |
CLI, hook, MCP proxy |
tenuo_demo.py |
Scripted tour + receipt trail |
CONTRIBUTING.md |
Maintainer notes |
Maintainer setup: CONTRIBUTING.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tenuo_claude_code-0.1.0.tar.gz.
File metadata
- Download URL: tenuo_claude_code-0.1.0.tar.gz
- Upload date:
- Size: 49.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ea739a922285dc64d212d1731089443956fb543cb30380e0539a5c30bfb7ea3
|
|
| MD5 |
664635e510a8c125e01d7871413c80ca
|
|
| BLAKE2b-256 |
11d9cc784c8e0aa50a0f14d05c8d9a186b99c31d3b963756f6044cd6f34c930d
|
File details
Details for the file tenuo_claude_code-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tenuo_claude_code-0.1.0-py3-none-any.whl
- Upload date:
- Size: 51.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b760ee0467a7231d1079717613ac828976ae9f303daefff996a85816eccc30dd
|
|
| MD5 |
f98e7f964609a7de23d62ab264ab3d37
|
|
| BLAKE2b-256 |
e9bd3e0a5499fc82e81ba364131d4ed68052fc83f38b017f145396968b346167
|