Tenuo for Claude Code
Claude Code's permissions decide which tools Claude can call. Tenuo decides what it can call them with.
Tenuo checks the arguments of every tool call against your policy before the call runs: which repo, which directory, which commands, which domains. That includes MCP tools, with no change to the MCP server. Each decision is written as a signed receipt.
Here Claude Code's own permissions allowed the GitHub MCP server's get_file_contents. The Tenuo policy allows one repository:
mcp:
server: github # must match the client MCP server name
enforce:
get_file_contents:
args:
owner: "exact:tenuo-ai"
repo: "pattern:tenuo"
default: deny
$ tenuo-claude audit
ALLOW [gov] mcp__github__get_file_contents -> mcp__github__get_file_contents authorized owner=tenuo-ai path=README.md repo=tenuo
DENY [gov] mcp__github__get_file_contents -> mcp__github__get_file_contents Constraint not satisfied owner=tenuo-ai path=README.md repo=tenuo-cloud
DENY [gov] mcp__github__get_file_contents -> mcp__github__get_file_contents Constraint not satisfied owner=torvalds path=README repo=linux
The same check covers Claude's own tools: reads and edits stay inside the project, the shell runs only the commands you list, and WebFetch reaches only the domains you name.
Why Tenuo?
| Claude Code permissions | Tenuo | |
|---|---|---|
| Matches | Tool name; command and path patterns for built-in tools | Any argument of any tool, including MCP tools |
| Decides | Allow, deny or ask the user | Allow or deny before the call, against a signed, expiring warrant |
| Evidence | Optional hook logs | Signed, hash-chained receipts (tenuo-claude audit --verify) |
| Subagents | Same rules as the session | Each declared subagent gets a narrower warrant |
| Teams | Managed settings | Managed settings plus an org-signed root, revocation and approvals (Cloud) |
Tenuo runs alongside Claude Code permissions. It checks the call whatever led the model to make it: prompt injection, a poisoned tool result, or a plain mistake.
Quickstart
One command to try it:
uv tool install tenuo-claude-code
tenuo-claude bootstrap --native --pack filesystem-dev
claude
That's it. bootstrap with --pack filesystem-dev generates a starter policy (read and edit your project, keep Tenuo's keys and hook wiring out of reach, a minimal shell), starts the authorizer, and runs a self-test. The first run also downloads the pinned authorizer binary to ~/.tenuo/bin. The pack starts in mode: dry-run, so nothing is blocked yet: open Claude Code, work normally, ask for something out of scope (say, reading a file outside the project), then:
tenuo-claude audit --verify
to see the signed decision log, including what would have been denied.
What the quickstart does
-
--pack filesystem-devgenerates a preconfigured policy:- Read, edit and search files in your project directory (paths resolved, symlinks followed)
- Never read or change Tenuo's own files: its keys and state,
tenuo.yaml,.mcp.json,.claude/settings*.json - Run only
lsandpwdin the shell; add your test and build commands toBash:. The shell check covers the command name, not its paths, so list only commands you'd allow on any path - Deny everything else by default
- Start in
mode: dry-run(log, don't block)
-
--nativeuses the native authorizer binary (no Docker needed) -
bootstrapmints a session warrant, wires the PreToolUse hook, starts the authorizer, and runs a test -
claudeopens Claude Code. The authorizer is now active; every tool call is checked.
After you see it working, edit tenuo.yaml to fit your project, switch to mode: enforce, and run:
tenuo-claude refresh
Available policy packs:
filesystem-dev— Read/write project files, minimal shell (default)github-mcp— GitHub MCP server, constrained API querieshttp-api-safe— WebFetch with SSRF protection and domain allowlists
Browse more: examples/policies/.
Limit an MCP server
Add the server to Claude Code as usual, then scope it. For the GitHub MCP server:
tenuo-claude init --pack github-mcp --param owner=myorg --param repos='service-*' --param approver=any-human
The pack starts in mode: dry-run, which logs decisions but does not block calls. Review the generated tenuo.yaml, change it to mode: enforce, then apply it and start the authorizer:
tenuo-claude refresh
tenuo-claude up
In enforce mode, reads and ordinary writes are limited to myorg/service-*; merges, pushes and file writes need a human approval (Tenuo Cloud; locally they are denied); everything else is denied. For another server, list its tools under mcp.enforce with a constraint per argument; see Policy.
For Cloud mode (org root, revocation, human approval):
tenuo-claude bootstrap --native --pack filesystem-dev --cloud
Then follow the Cloud setup prompts. Details: Cloud mode.
Install as a Claude Code plugin
The repo is also a Claude Code plugin marketplace. Inside Claude Code:
/plugin marketplace add tenuo-ai/tenuo-claude-code
/plugin install tenuo@tenuo
The plugin still needs the CLI (uv tool install tenuo-claude-code) and a policy (tenuo-claude bootstrap --native --pack filesystem-dev). What it adds:
- Hooks that stay out of the way. PreToolUse, PostToolUse, and SessionStart hooks run in every project, but they do nothing unless the project has opted in. Repos without Tenuo keep Claude Code's normal permissions.
- A fail-closed safety net. If a project is governed but its
.claude/settings.jsonwiring is missing (a fresh clone, a regenerated settings file), the plugin routes each call totenuo-claude _hookitself. If the CLI can't be found or crashes, the call is denied with a message saying how to fix it. - A one-line status at session start, plus
/tenuo:status(tenuo-claude check) and/tenuo:audit(tenuo-claude audit --verify).
How the plugin decides, per tool call:
| Project state | PreToolUse result |
|---|---|
No tenuo.yaml in the session directory or any parent |
Nothing (Claude Code permissions apply) |
tenuo.yaml but no state directory (never initialized) |
Nothing; session start says to run bootstrap |
<state>/disabled present (tenuo-claude disable) |
Nothing; tenuo-claude up or init re-arms it |
Tenuo hooks already wired by init/bootstrap (project, user, or managed settings) |
Nothing; the wired hook enforces, so no double receipts |
Initialized, not wired, tenuo-claude found |
Delegates to tenuo-claude _hook |
Initialized, not wired, tenuo-claude missing, crashing, or timing out |
Deny (fail-closed) |
TENUO_PROJECT_DIR set to a directory without tenuo.yaml |
Deny (fail-closed) |
The CLI is found via TENUO_CLAUDE_BIN, the project .venv, PATH, then the uv tool and pipx install locations. The launcher is stdlib Python (plugins/tenuo/scripts/tenuo_plugin.py); it makes no network calls itself. macOS and Linux only, like the CLI.
To try a local checkout: claude --plugin-dir ./plugins/tenuo.
How It Works
You define what Claude can do in tenuo.yaml:
name: my-project
sandbox: ./workspace
enforce:
Read: "subpath:{sandbox}" # Read only project files
Bash: "shlex:ls,pwd,cat,grep" # Only these commands
WebFetch:
domains: ["api.github.com"] # Only this domain
subagents:
analyst:
tools: [Read, Grep] # Analyst: read-only
default: deny # Block everything else
mode: dry-run # Log, don't block (switch to 'enforce' later)
Tenuo compiles this into a signed, expiring warrant (a capability token). Every Claude action is checked against the warrant before it runs:
- File reads — constrained by
subpath(can't escape the directory) - Shell commands — constrained by
shlex(only allowlisted verbs, no pipes/chaining) - Web fetches — constrained by domain, SSRF-safe
- MCP calls — constrained by tool name and per-argument rules
- Subagent spawns — gated to declared roles, each runs with narrower authority
The same boundary applies no matter why the model tried the call.
Constraints
subpath:DIR— path must resolve inside DIR (symlink-safe)shlex:verb,verb— command executable must be in list; pipes/chaining/subshells denieddomains:[host]— URL domain must match (with SSRF/encoded-IP hardening)oneof:a,b·exact:v·pattern:glob·regex:re·range:min,max— argument must satisfy the constraint- See Policy for the full list and examples
Day-to-Day Commands
tenuo-claude up # Start the authorizer
tenuo-claude refresh # Recompile after editing tenuo.yaml
tenuo-claude audit --verify # Review and verify the decision log
tenuo-claude down # Stop the authorizer
Full command reference: Commands.
Cloud Mode
For organizations: org-root warrants, instant fleet revocation, human approval gates, and a central audit trail.
tenuo-claude bootstrap --native --pack filesystem-dev --cloud
Setup takes ~2 minutes (Cloud tenant, runtime credentials, policy trigger). After that, Tenuo issues warrants signed by your org root, revokes them fleet-wide in ~30s, and sends signed receipts to your tenant for compliance audit.
Human approval is optional: gate any tool to require sign-off (Slack, Telegram, console, etc.) from a named approver.
See Cloud mode for details.
What's in Scope
Tenuo governs model-invoked tool calls on the PreToolUse path:
- Read, Write, Glob, Edit — file access
- Bash, Monitor, PowerShell — command execution
- WebFetch — HTTP requests
- MCP tools — downstream server calls (via proxy)
- Agent — subagent spawning
It does not govern:
- The
!TUI shell (operator command, not model-invoked) - Approved operator actions outside the PreToolUse boundary
Fail-Closed Design
- Missing or broken
tenuo.yamldenies every governed call until restored - Private keys are owner-only (
0600) and kept outside the project - In enforce mode a recursive
Grepruns with the policy and hook wiring files (tenuo.yaml,.mcp.json,.claude/settings*.json) excluded, and is denied if they can't be excluded. Keep secrets out of those files anyway (${VAR}references), sincedry-runblocks nothing - A broken receipt sink surfaces loudly (not silent data loss)
- Cloud-disconnected nodes fall back to local policy (no open bypass)
Policy
tenuo.yaml drives the whole system: warrant, authorizer, hooks, MCP proxy.
name: acme-backend
sandbox: ./workspace # a directory; {sandbox} expands to its absolute path
ttl_seconds: 3600 # optional: session warrant lifetime (default 3600 = 1h)
mode: enforce # block out-of-scope calls; 'dry-run' = log only
enforce:
Read: "subpath:{sandbox}"
Write: "subpath:{sandbox}"
Bash: "shlex:ls,pwd,echo,cat,grep"
WebFetch:
domains: ["api.github.com", "*.githubusercontent.com"]
allow: # permitted, unconstrained (logged)
- TodoRead
default: deny # anything unlisted is denied (recommended, fail-closed)
subagents: # optional: each role runs under a narrower warrant
analyst:
tools: [Read, Grep, Glob]
mcp: # optional: govern a downstream MCP server's tools
server: tenuo-files # client MCP server name (default: tenuo-files)
downstream: ./your_mcp_server.py # or {command: npx, args: [...], env: {...}}
enforce:
read_file: "subpath:{sandbox}"
run_query:
arg: database
constraint: "oneof:analytics,staging"
MCP capabilities include the server name: mcp__<server>__<tool>. A different server with the same tool name does not inherit these grants. For upgrades, see 0.7.0 migration notes.
Any MCP client
tenuo-claude mcp-proxy serves mcp.downstream over stdio and checks each call against the policy before forwarding it, so the same rules apply in Cursor, Claude Desktop or any other MCP client. The downstream can be any stdio server. Env values can reference the proxy's environment, which keeps tokens out of tenuo.yaml:
mcp:
server: github
downstream:
command: docker
args: [run, -i, --rm, -e, GITHUB_PERSONAL_ACCESS_TOKEN, ghcr.io/github/github-mcp-server]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}"
enforce:
get_file_contents:
args: {owner: "exact:myorg", repo: "pattern:service-*"}
default: deny
Then point the client at the proxy instead of the server, with tenuo-claude up running in the project:
{
"mcpServers": {
"github": {
"command": "tenuo-claude",
"args": ["mcp-proxy"],
"env": {"TENUO_PROJECT_DIR": "/path/to/project", "GITHUB_TOKEN": "..."}
}
}
}
A denied call returns an MCP error result (isError: true) naming the refused constraint, and is recorded in tenuo-claude audit.
Constraints
| Constraint | Applies to | Checks |
|---|---|---|
subpath:DIR |
Read, Write, Edit, Glob, Grep | Path resolves inside DIR (symlink-safe) |
shlex:a,b,c |
Bash, Monitor, PowerShell | Executable is one of a,b,c; pipes/chaining/subshells denied |
domains:[list] |
WebFetch | URL domain matches allowlist; SSRF/encoded-IP hardening built-in |
oneof:a,b |
any tool | Value is in set |
notoneof:a,b |
any tool | Value not in set |
exact:v |
any tool | Value equals exactly |
pattern:glob |
any tool | Value glob-matches |
regex:re |
any tool | Value regex-matches |
range:min,max |
any tool | Value in numeric range |
urlpattern:url |
any tool | Value matches URL glob |
cidr:n/m |
any tool | Value in IP range |
{sandbox} expands to the directory in sandbox:. You can point subpath: at any path. Tools unlisted aren't individually governed; they're caught by default.
For internal egress, WebFetch also accepts cidrs: (e.g., cidrs: ["10.0.0.0/8"]) to allow by IP range. It's off by default; add deliberately.
Command tools. Bash, PowerShell, and Monitor execute commands independently; govern each in enforce:. Use shlex for POSIX (Bash/Monitor) and pattern/regex for PowerShell (different dialect).
MCP arguments. Under mcp.enforce:, a bare constraint targets path. Use arg: NAME for other arguments, and args: {NAME: constraint, …} for multiple constraints.
Three lists, plus two switches.
enforce:constrained (blocked if constraint fails)allow:unconstrained (always allowed, logged)default:catch-all for unlisted toolsdefault: denyrecommended (fail-closed)default: approveroutes to human approval (Cloud only)
mode:global posturemode: enforceblocks denied callsmode: dry-runlogs decisions, blocks nothing (use to shadow a policy before enforcing)
subagents:declares roles; each runs with attenuated authorityttl_seconds:warrant lifetime in seconds (default 3600 = 1h);upre-mints
After any edit, run tenuo-claude refresh.
Ready-made policies: examples/policies/.
Commands
Day to day: up (start), audit (review), refresh (after editing policy).
| Command | What it does |
|---|---|
onboard |
Interactive first-run wizard (--local / --cloud); same as bootstrap but with prompts. |
bootstrap |
Non-interactive setup: scaffold policy if none → init → up → verify. --cloud for Cloud mode; --pack PACK to use a policy pack. |
init |
Compile existing tenuo.yaml: mint warrant, wire hook and MCP proxy. |
up / down |
Start / stop the authorizer (auto-selects Docker or native; --native forces native). |
refresh |
Recompile after editing tenuo.yaml (restarts authorizer if running). |
verify [--deep] |
Self-test the policy against the authorizer (no Claude session needed). --deep adds SSRF/encoded-IP matrix and Bash deny cases. |
audit [--tail N] [--verify] |
Show the decision log (<state>/receipts.jsonl). --verify checks receipt signatures, hash-chain links, and embedded evidence. |
check |
Preflight: dependencies, wiring, audit-sink health, leaked keys, Cloud bindings. |
status |
Warrant, mode, audit-sink health, Cloud summary. |
install-authorizer |
Install the pinned native authorizer to ~/.tenuo/bin. |
state-dir |
Print this project's state directory. |
bench [--json] |
Measure per-call overhead on your machine. |
revoke |
Revoke the current session warrant. |
The warrant is short-lived (~1h TTL); up refreshes it before expiry. Generated files (don't commit): .claude/settings.json (hooks), .mcp.json (MCP proxy).
State. Keys, warrant, gateway config, receipts and Cloud credentials live outside the project, in ~/.tenuo/projects/<project>-<hash>/, so nothing scoped to the project tree can reach them. tenuo-claude state-dir prints the path; <state>/… in these docs means a file in it. A project set up by an older release keeps using <project>/.state until the next tenuo-claude refresh, which moves it.
Cloud Mode
For organizations: org root, fleet revocation, human approval, central audit.
Setup (first time)
-
Create a Tenuo tenant at cloud.tenuo.ai. Complete the Infrastructure onboarding (provisions KMS-backed signing keys).
-
Get credentials:
- Runtime key (
tenuo_ct_…) from Agents → Quick Connect → Authorizer Only →<state>/cloud.env - Tenant-admin key (
tc_…) from Settings → API Keys → Create (admin role) →~/.tenuo/admin.env
- Runtime key (
-
Bootstrap with Cloud:
tenuo-claude bootstrap --native --pack filesystem-dev --cloud
Behind the scenes:
tenuo-admin setupregisters this project, creates a Cloud trigger fromtenuo.yaml, and mints a root-signed warrant. -
(Optional) Add human approval:
- Create an approver identity in Cloud (Dashboard → Channels → Identity Bindings)
- Give it a notification channel (Slack, Telegram, console, etc.)
- Gate any tool with
approval:intenuo.yaml
Day to day (Cloud)
tenuo-claude check && tenuo-claude up
If check reports a Cloud bindings failure, run tenuo-admin setup and retry.
After editing tenuo.yaml:
tenuo-claude refresh
The Cloud-issued warrant stays active until revoked (or TTL expires). To revoke fleet-wide:
tenuo-claude revoke
~30 seconds later, all active sessions enforcing that warrant exit and must re-authorize.
Important: Once Cloud is configured, don't re-run plain bootstrap (it reverts to local mode). Use check && up on future sessions.
Human Approval (Cloud)
Add approval: to any enforced tool:
enforce:
WebFetch:
domains: ["api.github.com"]
approval: true # all URLs gated
delete_deployment: # MCP tool
arg: target
constraint: "oneof:staging,prod"
approval:
exempt: ["staging"] # staging bypasses approval
When Claude tries a gated action:
- The hook opens a Cloud approval request
- The approver gets a Slack/Telegram/console notification
- They review and approve/deny
- The decision is recorded in the signed receipt (audit trail shows who approved)
Setup: See docs/DETAILS.md § Approval setup runbook.
For Cloud, receipts are sent to your tenant (central audit). Locally, receipts are always written to <state>/receipts.jsonl.
Security Model
Tenuo runs alongside Claude Code permissions; it doesn't replace them. The difference:
- Claude Code permissions: Prompt the user; can be bypassed with
--dangerously-skip-permissions - Tenuo warrant: Signed, expiring authority enforced by an authorizer before each call; cannot be disabled by the user or model
The warrant is a capability token: it lists what Claude can do and for how long. Each action is checked against the warrant before it runs. The same boundary applies regardless of why the model tried the call: prompt injection, hallucination, poisoned tool output, or direct user request.
Receipts: Every decision is signed with Ed25519 and hash-chained for tamper-evidence. Local audit --verify checks signatures, hash-chain links, warrant chains, and deterministic constraint replay. Cloud receipts are independently verified by the tenant.
Fail-closed: Missing or broken tenuo.yaml denies every governed call. Keys are owner-only and kept outside the project. A broken receipt sink surfaces loudly (not silent data loss).
Rolling out to a team: Keep tenuo.yaml in version control. Push hook/MCP wiring through Claude Code managed settings. Use Cloud for org-root warrants, central audit, and revocation. See docs/DETAILS.md § Managed deployment for the rollout checklist and hardened socket setup.
Turning it off: tenuo-claude disable unwires the hook (Claude stops calling the authorizer) and stops the authorizer, leaving policy and warrant in place. tenuo-claude uninstall also deletes the state directory (warrant, keys, credentials). tenuo.yaml is never touched.
Build from Source
For development or running from a git checkout:
git clone https://github.com/tenuo-ai/tenuo-claude-code.git
cd tenuo-claude-code
uv venv && uv sync && source .venv/bin/activate # Windows: .venv\Scripts\activate
chmod +x bin/tenuo-claude
uv run tenuo-claude install-authorizer # only if not using Docker
Run via ./bin/tenuo-claude --help, uv run tenuo-claude --help, or pip install -e .. Re-run tenuo-claude init (or refresh) after moving the repo or reinstalling.
Contributors: CONTRIBUTING.md.
Performance
Authorization is ~1–3 ms per call; the PreToolUse hook adds ~100–200 ms (mostly process startup). Measure on your machine:
tenuo-claude bench
About
Repository: tenuo-ai/tenuo-claude-code
PyPI: tenuo-claude-code
Tenuo core: tenuo-ai/tenuo
License: Apache 2.0
| Path | |
|---|---|
| Package source | src/tenuo_claude_code/ |
| Policy packs | examples/policies/ |
| Reference demo | demo/ |
| Implementation details | docs/DETAILS.md |
| Troubleshooting | docs/TROUBLESHOOTING.md |
| Security | SECURITY.md |
Metadata
Release files for tenuo-claude-code 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| tenuo_claude_code-0.7.0.tar.gz | 667.9 kB | Without provenance | Details for tenuo_claude_code-0.7.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| tenuo_claude_code-0.7.0-py3-none-any.whl | Python 3 | none | any | Details for tenuo_claude_code-0.7.0-py3-none-any.whl |
Total release size: 799.6 kB
Release files / tenuo_claude_code-0.7.0.tar.gz
| Download URL | tenuo_claude_code-0.7.0.tar.gz |
|---|---|
| Size | 667.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3874f09c7159c6cdeb97c27142eb3e723d486804e0c32280cc80a3f69126c1e1
|
|
BLAKE2b-256 checksum How to use checksums |
a37716d84b45c5cd56332f2ac2ad87567605c8cda0c2dccc11703234a8d31533
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","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}
|
Release files / tenuo_claude_code-0.7.0-py3-none-any.whl
| Download URL | tenuo_claude_code-0.7.0-py3-none-any.whl |
|---|---|
| Size | 131.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1a619eeb26cdcfc64498eb7714ad8e198483d6f87f86f69ee5edb5bf6ff06e3c
|
|
BLAKE2b-256 checksum How to use checksums |
a1760bb48d0af92de1c7fbe04b776f70d4f774b65279d8520b7cd5f0b5a36c1a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","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}
|
Security for tenuo-claude-code 0.7.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.