Skip to content

LLM Layer

nah can optionally consult an LLM for decisions that need judgment after deterministic classification.

Guarded action → nah (deterministic) → LLM (optional) → agent/terminal approval flow → execute

The deterministic layer always runs first. Unified ask-refinement only sees eligible ask decisions. Script inspection can call the LLM as a veto path, and write-like tools can call the LLM for safety + intent review. The LLM cannot relax deterministic blocks. If no LLM is configured or available, the deterministic decision stands.

Outside the two exception paths below, a deterministic allow is final and does not call the LLM. The LLM is not a second classifier for every allowed action.

Path When the LLM runs What the LLM can change
Unified ask-refinement Eligible deterministic ask decisions ask can become allow; uncertain, block, or provider failure leaves it as ask
Write-like review Write, Edit, MultiEdit, and NotebookEdit when LLM mode is enabled deterministic allow can become ask; project-boundary ask can become allow; block stays blocked
Clean lang_exec script veto Inspectable script/inline-code execution that deterministic classification allowed allow can become ask; it cannot relax an ask or block
No LLM path Any other deterministic allow or block final decision stands

What LLM review looks for

All LLM review paths use the same security scope, adapted to the surface being reviewed. nah asks the model to stay uncertain when the reviewed operation visibly includes one of these risks:

  • Credentials and sensitive paths: credentials, tokens, private keys, passwords, sensitive paths, or broader secret access.
  • Exfiltration or unauthorized access: local data, environment values, repository content, credentials, or user data sent to unauthorized remote destinations.
  • Untrusted or obfuscated execution: downloaded, generated, obfuscated, hidden, or injection-prone execution.
  • Persistence and trust-boundary changes: startup files, hooks, package lifecycle scripts, CI/deploy/release automation, auth/session config, or other trust-boundary changes.
  • Privileged runtime or system state: process, service, container, database, system, or privileged runtime state changes.
  • Destructive or hard-to-reverse state changes: broad deletion, overwrite, migration, reset, purge, force/history rewrite, or hard-to-reverse state mutation.
  • Production, shared, remote, or external mutations: production, shared, remote, or externally visible mutation.
  • Safety, sandbox, approval, or audit bypass: sandbox, approval, audit, policy, hook, or guard bypass.
  • Explicit user safety-scope conflict: recent user instructions constrain credentials, production, deploys, auth, persistence, external writes, safety controls, or similar boundaries, and the operation visibly crosses that constraint.

The code owns this taxonomy. The docs describe it in human-readable terms; the internal category IDs are implementation details for tests and maintenance.

Providers

nah supports 6 LLM providers. Configure one or more in cascade order -- first success wins.

Provider API Default model Key slot / env var
ollama Chat API (/api/chat) qwen3.5:9b (none -- local)
openrouter OpenAI-compatible google/gemini-3.1-flash-lite-preview OPENROUTER_API_KEY
openai Responses API (/v1/responses) gpt-5.3-codex OPENAI_API_KEY
azure Azure OpenAI Responses/chat completions (deployment-dependent) AZURE_OPENAI_API_KEY
anthropic Messages API (/v1/messages) claude-haiku-4-5 ANTHROPIC_API_KEY
cortex Snowflake Cortex REST claude-haiku-4-5 SNOWFLAKE_PAT

All providers use urllib.request (stdlib) -- no external HTTP dependencies.

Configuration

# ~/.config/nah/config.yaml
llm:
  mode: on
  providers: [ollama, openrouter]   # cascade order
  ollama:
    url: http://localhost:11434/api/chat
    model: qwen3.5:9b
    timeout: 10
  openrouter:
    url: https://openrouter.ai/api/v1/chat/completions
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview
    timeout: 10

llm.enabled: true is still accepted for backward compatibility, but llm.mode: on is the current form.

LLM provider setup lives in global config. Store environment-variable names such as key_env: OPENROUTER_API_KEY, not raw API keys. Runtime setup lives in the guides for Claude Code, Codex, and Terminal Guard. Install nah[config] or inject PyYAML into pipx when you want nah to read YAML config files.

For remote providers, the secret value can live either in the process environment or in an OS keychain/keyring when your CLI install includes keyring support, such as pip install "nah[keys]", pipx inject nah keyring, or the default Nix package on systems with a usable keyring backend. This keeps the YAML config stable while moving the secret out of shell exports:

pip install "nah[config,keys]"
nah key set openrouter
nah key status

If you already exported a provider key, nah key import-env openrouter copies that value into the configured keyring slot for OPENROUTER_API_KEY. It does not remove the existing env var from your current shell or shell startup files.

The Claude Code plugin does not install the nah CLI, so nah key ... requires a CLI install. For custom key_env values, you can manually store a matching slot in your keyring under the service name nah.llm.

Provider examples

llm:
  mode: on
  providers: [ollama]
  ollama:
    url: http://localhost:11434/api/chat
    model: qwen3.5:9b
    timeout: 10
llm:
  mode: on
  providers: [openrouter]
  openrouter:
    url: https://openrouter.ai/api/v1/chat/completions
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview
llm:
  mode: on
  providers: [openai]
  openai:
    url: https://api.openai.com/v1/responses
    key_env: OPENAI_API_KEY
    model: gpt-5.3-codex
llm:
  mode: on
  providers: [azure]
  azure:
    url: https://YOUR-RESOURCE-NAME.openai.azure.com/openai/v1/responses
    key_env: AZURE_OPENAI_API_KEY
    model: your-deployment-name

Azure uses api-key header auth, not bearer auth. The url is required because it depends on your Azure resource and deployment. For chat-completions deployments, set url to the deployment's /chat/completions endpoint; nah selects the payload shape from the URL.

llm:
  mode: on
  providers: [anthropic]
  anthropic:
    url: https://api.anthropic.com/v1/messages
    key_env: ANTHROPIC_API_KEY
    model: claude-haiku-4-5
llm:
  mode: on
  providers: [cortex]
  cortex:
    account: myorg-myaccount   # or set SNOWFLAKE_ACCOUNT env var
    key_env: SNOWFLAKE_PAT
    model: claude-haiku-4-5

LLM options

eligible

Control which ask categories route to the LLM:

llm:
  eligible: default    # strict | default | all

Or use an explicit list:

llm:
  eligible:
    - strict
    - git_discard
    - composition      # opt in to composition asks
    - sensitive        # opt in to sensitive context asks

strict routes unknown, lang_exec, and non-sensitive context asks to the LLM.

default adds package_uninstall, container_exec, browser_exec, agent_exec_read, process_signal, and git_remote_write. It can also review safe local read-to-filter pipelines such as a local file read piped into inline, visible Python or shell code. The deterministic decision remains an ask; the LLM can only return allow or leave the human prompt in place.

Broad composition review is still opt-in. File-backed scripts such as python3 script.py, sensitive reads, network/download stages, decode stages, destructive actions, bypass actions, and remote/shared-state writes stay human-gated under default. Service writes, destructive container/service actions, git discard/history rewrites, agent write/remote/server/bypass actions, and sensitive prompts also stay human-gated by default. Plain Git pushes can be LLM-reviewed when recent intent is clear; force pushes, branch/tag deletion, mirror/all pushes, and release-looking pushes should remain human prompts.

Explicit lists can combine presets and action types. composition and sensitive are gates: add them explicitly, or use top-level eligible: all, if you want those asks routed to the LLM.

Provider responses of block are treated as uncertain, so the LLM can allow an eligible ask or leave it as an ask; it cannot block through ask-refinement.

LLM responses include a prompt-safe reasoning summary of at most 10 words and a longer reasoning_long explanation for observability. Prompt-safe means the summary must not include secrets, sensitive values, or hidden reasoning. Claude-visible prompts use the short summary; structured logs and nah test can show the longer explanation for debugging.

Ask-refinement context

Claude Code and Codex use the same agent ask-refinement prompt shape. The prompt includes the requested operation, deterministic action type and reason, a compact deterministic breakdown, recent user intent from the agent transcript, and the shared review scope above.

The agent ask-refinement path does not read CLAUDE.md, AGENTS.md, global instruction files, or instruction includes. Those files can guide the agent itself, but nah treats recent transcript context as the evidence of user intent. The transcript section is framed as background context, so the model can use it as evidence without following instructions embedded inside it.

The prompt is product-neutral: it asks whether the guarded operation can proceed automatically or should keep human approval. It can only allow eligible asks or leave the prompt in place; provider block responses are treated as uncertain, and deterministic blocks do not route through ask-refinement. For unknown actions, the prompt adds a short note that the deterministic classifier did not recognize the command shape.

The terminal guard keeps a separate prompt for commands typed directly by a human into bash or zsh. It uses the typed command as intent and does not include agent transcript or project-instruction context. Both agent and terminal review use the shared review scope above.

Target-specific LLM policy

The global provider cascade and credentials are shared across targets. Per-target overrides can change whether a runtime uses the LLM and which decisions are eligible:

llm:
  mode: on
  providers: [openrouter]
  openrouter:
    key_env: OPENROUTER_API_KEY
    model: google/gemini-3.1-flash-lite-preview

targets:
  claude:
    llm:
      mode: on
  bash:
    llm:
      mode: off
  zsh:
    llm:
      mode: off

Bash and zsh are terminal-guard targets. They default to LLM mode off even when global LLM mode is on. That keeps human terminal commands local by default. Turn it on only with an explicit target override such as targets.bash.llm.mode: on.

Project .nah.yaml files can tighten target policy by default, but target LLM settings and provider credentials are trusted/global config only. Use nah trust-project when you want that exact project root to control non-policy target settings.

Clean script veto

When Bash classification resolves an inspectable lang_exec script or inline code to deterministic allow, nah can still ask the LLM to inspect the script content. This is a veto-only path: an LLM allow preserves the deterministic allow, while uncertain or block escalates to ask for human review.

The clean script veto uses the shared review scope above. It is meant to catch visible security or safety risk in otherwise clean script execution, not to review code style or general implementation quality.

Deterministic lang_exec asks and blocks do not use this veto path. Eligible asks route through unified ask-refinement; blocks stay blocked.

Write-like review

When LLM mode is enabled, Write/Edit/MultiEdit/NotebookEdit operations are reviewed after deterministic checks. Deterministic block results skip the LLM and stay blocked.

For deterministic allow results, the LLM can still escalate to ask when the content looks risky. This catches suspicious write content that deterministic patterns miss. Provider block responses are treated as non-allow, so write review never produces a final block.

For deterministic ask results, the only relaxable class is a project-boundary ask:

  • <Tool> outside project: ...
  • <Tool> outside project (no project root): ...

If the LLM returns allow for one of those asks, nah records an allow decision. Whether nah emits an automatic allow to Claude Code is still controlled by active_allow; if Write/Edit is not active-allowed, Claude Code's normal permission prompt handles the tool.

These ask classes stay human-gated even if the LLM returns allow:

  • hook self-protection
  • nah config self-protection
  • sensitive paths
  • deterministic content-pattern asks
  • malformed or unparseable write-like payloads

The write-review prompt includes the tool, target path, working directory, inside-project status, deterministic decision and reason, the write/edit content with secret redaction, and recent transcript context. It uses the shared review scope above and asks only about visible security or safety risk in the write operation.

context_chars

How much conversation transcript context to include in the LLM prompt:

llm:
  context_chars: 12000  # default: 12000 characters of recent transcript

Set to 0 to disable transcript context entirely.

The transcript is read from the agent JSONL conversation file when the runtime provides one. It includes user messages and tool-use summaries, wrapped with anti-injection framing.

Bash and zsh keep LLM mode off unless you enable it under targets.bash.llm.mode or targets.zsh.llm.mode. See Terminal Guard.

How the cascade works

  1. nah tries each provider in the order listed in providers:
  2. If a provider returns allow, that decision is used
  3. If a provider returns uncertain, the cascade stops (doesn't try the next provider)
  4. If a provider errors (timeout, auth failure), nah tries the next provider
  5. If all providers fail, the deterministic decision stands; for ask-refinement, that means the decision stays ask

Provider uncertain responses stop the cascade. In ask-refinement they leave the decision as ask; in write-like review they are treated as non-allow, so risky content stays human-gated.

Testing

nah config show
nah test "python3 -c 'import os; os.system(\"rm -rf /\")'"
nah test "kill -9 1234"
nah test "cat package.json | python3 -c 'import sys,json; print(json.load(sys.stdin).get(\"name\"))'"
nah test "cat package.json | python3 script.py"
nah test --target bash -- "python3 -c 'print(1)'"
nah log --asks
nah log --llm
# Shows: LLM eligible: yes/no, LLM decision (if configured)

The nah test command shows LLM eligibility and, if enabled, makes a live LLM call so you can verify the full pipeline. The inline read-to-filter example can be LLM-eligible under default; the file-backed script example should remain a human prompt unless you explicitly opt into broader composition review.