Skip to main content

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

  1. Sign in at cloud.tenuo.ai

  2. AgentsQuick Connect

  3. Connection type: Authorizer Only (holder agent registration is done by tenuo-admin setup, not Quick Connect)

  4. 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_URL

    • TENUO_API_KEY instead (see cloud.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 policytenuo-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. Verifypython3 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:

  1. Connect a notification channel (Slack or Telegram) — Adding notification channels
  2. 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 downinitup tenuo-admin setup (if needed) → downup
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 + Claude Code — every tool call is checked against policy before it runs

                         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.

Authorization receipts in Tenuo Cloud

Drill into an approved off-allowlist WebFetch to see the approval bound to that specific call — approver, timestamp, and cryptographic request hash:

Receipt detail with human approval

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 from harness_tools.yaml (extend with audit_extra:).
  • default: deny: everything else blocked with a receipt.
  • mcp.enforce: bare downstream tool name + path arg 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

  1. Local evalSetup → Local mode: init, up, doctor, tenuo_demo.py.
  2. Observe-onlymode: 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 on WOULD-DENY rows, then set mode: enforce.
  3. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tenuo_claude_code-0.1.0.tar.gz (49.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tenuo_claude_code-0.1.0-py3-none-any.whl (51.8 kB view details)

Uploaded Python 3

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

Hashes for tenuo_claude_code-0.1.0.tar.gz
Algorithm Hash digest
SHA256 2ea739a922285dc64d212d1731089443956fb543cb30380e0539a5c30bfb7ea3
MD5 664635e510a8c125e01d7871413c80ca
BLAKE2b-256 11d9cc784c8e0aa50a0f14d05c8d9a186b99c31d3b963756f6044cd6f34c930d

See more details on using hashes here.

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

Hashes for tenuo_claude_code-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b760ee0467a7231d1079717613ac828976ae9f303daefff996a85816eccc30dd
MD5 f98e7f964609a7de23d62ab264ab3d37
BLAKE2b-256 e9bd3e0a5499fc82e81ba364131d4ed68052fc83f38b017f145396968b346167

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page