Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Permissions

Every tool call the model makes has a level, and the level decides whether it runs silently, runs where you can see it, or stops to ask. The levels are fixed by what a tool does; configuration can only make heph ask less about calls that would have asked, or refuse calls outright. Nothing can make a tool quieter than its level.

Authorization and execution locks are independent. Every matching lock applies after authorization, including auto-approved, session-approved, remembered, and --yes calls. Waiting does not count as a second tool attempt, and denial acquires nothing.

The levels

LevelNameWhat happensTools
0TransparentRuns silentlyEverything that reads: view, outline, ls, glob, grep, stat, log, show, diff, status, branches, blame, search_history, read_turn, which, man, job_status, config_view; todos; and ask_user
1NotifyRuns; you see it happenThe writers: edit, create, search_replace; web_fetch to a host on the allowlist
2ApproveStops and asksshell, background, job_kill; web_fetch to any other host
3EscalateAsks, and --yes alone is not enoughconfig_edit: live configuration, always once-only approval

Why edits do not ask: they land in the worktree, not in your files, and review stands between the two. Every edit will be read before it reaches you, so asking about each one would only add prompts without adding safety.

Why shell asks, and background and job_kill with it: a command can do anything, including reach outside the worktree, and a process the model started is one it can also stop (Background Processes). Why web_fetch asks about a host it does not know: a GET carries its whole URL to whoever runs that host, and only you can say whether that host is one you would hand a URL to.

Persistent denial guidance

Put a reason beside a deny rule to explain the constraint and the next step:

[permissions]
deny = [
    { pattern = "exec(npm) args(*)", reason = "This project uses pnpm. Use exec(pnpm) instead." },
]

An npm call is refused without a prompt or execution. Its error tool result returns the pattern and reason to the model, and the same explanation appears in developer status. Every matching deny entry is reported, user entries before project entries, so broad rules do not hide narrower guidance. Reasons reach the selected model only on denial; keep them concise and avoid secrets. They are ordinary feedback, not higher-priority instructions. The suggested pnpm call still needs its own permission check. This is separate from interactive N feedback for one call. See Permissions config for validation, layering, and multiline reasons.

The prompt

A Level 2 call that nothing has approved opens this. The dialog's border also shows a muted Level 2 (approve) label that stays visible while scrolling. Plain-text prompts show the level above the tool and call. The label gives the tool's own level, before any repeated-call escalation; the explanation of why it is asking remains separate:

┌ permission ─────────────────────────────────────────────────────────────────┐
│exec   in worktree of ref main   (mirrors /home/you/project)                  │
│                                                                              │
│cargo test --lib auth                                                         │
│                                                                              │
│  why: Run the suite to confirm the fix compiles and passes.                  │
│                                                                              │
│y  this call only                                                             │
│a  this session:    exec(cargo) args(*)                                       │
│   = any exec of cargo, any arguments; only cargo is run, and each argument   │
│     is passed to it literally, never through a shell                         │
│A  remember:        the same, written to project config [permissions]      │
│e  edit the pattern before a or A                                             │
│n  deny                                                                       │
│N  deny with reason                                                           │
└──────────────────────────────────────────────────────────────────────────────┘

This is an exec call -- one program run directly. Environment variables go in its optional env map, not through the env program or a shell prefix:

{"program": "cargo", "args": ["test"], "env": {"RUST_BACKTRACE": "1"}}

The overrides are shown before the command in the prompt. They apply only to that child, and their values are passed literally, without shell expansion. Heph's configured command environment and markers take precedence, and git redirection remains disabled. A saved exec(cargo) args(*) approval still covers the call: patterns match the actual program and arguments, not env.

A shell command (one that needs a pipe, a redirect, globbing, or chaining) looks the same, but the pattern it offers is the exact command, because no wildcard is safe over a string a shell parses.

It says exactly what it is asking, in this order:

  1. The tool and where it runs. For exec and shell, the worktree and which of your directories it mirrors. For web_fetch, that it is an in-process GET with no body and no headers of the model's choosing. If the call selects execution locks, a Locks if approved: build-output, test-db line follows, before the call. It lists every selected name in sorted order, without duplicates; no line is shown when no locks match. These are the locks for this call, not the broader pattern you might approve. Showing the prompt acquires nothing; approval allows the call to wait for and take this same set.

  2. The call, verbatim. The whole command or URL, not a summary.

  3. The model's context. why: shows the latest nonempty assistant text before the call in the same response. If none exists, Model thinking: shows the latest available reasoning text or provider summary before the call instead. With neither, the prompt says No model context available for this call. Encrypted or withheld reasoning is not shown. Thinking is display-only, not a stated rationale for write or commit attribution; no extra model request is made.

  4. Why it is asking, when the level alone would not have: today, the repeated-call anomaly below.

  5. The answers, each with the pattern it would remember and, on the = line, what that pattern covers in plain words. The sentence is generated from the pattern, so it is always literally true.

A long call (a commit message, a script) scrolls: Up, Down, PgUp, PgDn, or the mouse wheel move through it, a marker counts the lines out of view, and compact action keys stay at the bottom of the box whatever is scrolled. Long scope explanations scroll with the call. After e and Enter, both the shown scope and its expansion update; editing does not remove A from an otherwise eligible prompt.

Long thinking initially shows a preview of at most 400 characters and four source lines. t expands it; t again collapses it. Both switches return to the top of the dialog without approving the call. The dialog stays the same size, and compact approval keys remain visible while expanded text scrolls. Plain-text prompts show the full available context.

The status bar shows approve? while the prompt is up, and a notification goes out if you are looking elsewhere.

The answers

KeyMeaning
yThis one call.
n (or Esc)Deny. The model is told the call was denied and continues.
NOpen a reason input. Heph waits for Enter, then augments the denial returned to the model with your feedback.
aThis call, and every call matching the pattern, until you leave the session.
AThe same, and the pattern is written to the effective original project config under [permissions] promoted, so it holds in future sessions.
eEdit the pattern first. Type a new pattern and press Enter; the = line updates. Then a or A remembers the edited pattern.

The pattern heph offers is a generalization of the call: for an exec call, the program with args(*) (exec(cargo) args(*)); for a path, its directory and * (edit(src/*)). It is a starting point. e lets you narrow it (exec(cargo) args(test *)). A shell command is offered exactly, with no wildcard -- e can change the command, but the saved pattern is still one exact command.

A is offered only on ordinary Level 2 prompts with a project destination. An anomaly prompt (below) is about this turn and cannot persist approvals. config_edit has no rememberable scope: it always asks for this one change.

Promotion writes immediately to the original project's effective config file, not a shadow copy, and honors --project-config. Its heading names that path. A failed save is reported as session-only. Permission and network policy are reread before subsequent tool decisions, including in other running sessions. See Editing live configuration for approved edits to other settings and restart requirements.

web_fetch and hosts

A fetch is decided by its host, not by a pattern over the URL. Hosts on the allowlist fetch at Level 1: the call shows in the conversation as it runs, and nothing asks. The allowlist is the curated defaults (docs.rs, crates.io, doc.rust-lang.org, github.com, raw.githubusercontent.com, developer.mozilla.org, stackoverflow.com, en.wikipedia.org), plus whatever [network] allow and promoted add (Network). Any other host opens a prompt:

┌ permission ─────────────────────────────────────────────────────────────────┐
│web_fetch   GET, in process: no body, no headers of the model's choosing;    │
│    a redirect to another host asks again                                     │
│                                                                              │
│  https://docs.tokio.rs/tokio/topics/shutdown                                 │
│                                                                              │
│  why: Confirm how a Notify wakes every waiter before relying on it.          │
│                                                                              │
│  y  this call only                                                           │
│  a  this session:    docs.tokio.rs                                           │
│                      = any https:// GET to exactly docs.tokio.rs (not        │
│    subdomains), by web_fetch only; shell has no network                      │
│  A  remember:        the same, written to project config [network]        │
│    promoted                                                                  │
│  e  edit the host before a or A                                              │
│  n  deny                                                                     │
│  N  deny with reason                                                         │
└──────────────────────────────────────────────────────────────────────────────┘

a allows the exact host for the session; A writes it to [network] promoted. A subdomain is another host and asks on its own. e edits the host, not a pattern: there are no wildcards, because on a shared host like github.io every subdomain is someone else's server.

Two things always ask and are never remembered: a plain http:// URL, whose whole path is visible to every network hop, and a redirect. web_fetch follows redirects that stay on the approved host and stops at one that leaves it, telling the model where the page went; fetching the new URL is a new call, and the new host goes through all of this again.

[network] deny refuses a host before anything else is considered, session approvals included, and a [permissions] deny of web_fetch refuses every fetch.

Patterns

A pattern is tool(glob), a bare tool for every call of it, or exec(program) args(glob). The glob matches the call's principal: the command for shell, the URL for web_fetch, the path for a file tool.

For path tools (view, ls, glob, grep, stat, edit, create, search_replace, blame), * stops at / and ** does not, as in .gitignore. For URLs, * matches any text.

A shell or background pattern is an exact command with no wildcard: a wildcard there would cover the shell metacharacters that chain a second command (shell(cargo *) matching cargo x; rm -rf ~). To approve a program with a wildcard over its arguments, use exec, which runs the program directly with no shell: exec(cargo) args(*) matches cargo test --lib -- --nocapture and any other cargo call, and only cargo.

Patterns live in three places, checked in this order:

  1. deny in config. A match refuses the call at any level, before anything else is considered. The model is told which pattern denied it.

  2. approve and promoted in config, then approvals given with a this session, then the run's --allow-tools, --yes, and --yes-really flags. A match on a Level 2 call runs it without asking.

  3. Otherwise, the prompt.

deny is the one that can make a Level 0 or 1 tool stop: deny = ["edit(Cargo.lock)"] refuses that edit, and the model learns why.

The Permissions chapter has the config syntax.

The repeated-call anomaly

Heph looks for recent repetition without progress, not the total number of calls in a long turn. Its last 32 completed attempts include refused calls. Unchanged results, calls dominating recent activity, and short recurring cycles can trigger assessment. Cycles compare content states and outcomes, so repeated edit/test work is normal when the content changes. Rereading an unchanged file or alternating between two patches with the same failures remains suspicious. An unrelated edit is not proof that another retry will help.

At a soft checkpoint, a fast model examines the last six completed exchanges and completed calls from the current response. It reports progress, loop, or uncertain, with observations tied to those exchanges. Observed progress clears soft suspicion. Looping, uncertainty, timeout, invalid output, or unavailable routing brings up the permission prompt with the recent pattern and diagnosis. With defaults, an unchanged read can be checked before its third attempt. Commands get three completed attempts first, accommodating transient build-cache failures. Running jobs get eight unchanged polls first; elapsed time alone is not new information.

The default hard checkpoint of 12 applies to sustained unchanged outcomes or cycles, not twelve legitimate test runs spread across productive work. Frequent calls producing different information can need assessment without consuming this hard allowance. Running-job polling has a human threshold of at least 32. New evidence starts a fresh episode, and old suspicion expires after a gap.

y approves this call and clears the turn's current pattern history and allowances. n and N deny it, with optional feedback to the working model. These prompts offer no a, A, or scope editor. Neither stored approvals nor --yes clear a checkpoint; unattended calls are refused when a human is required.

The classifier never approves commands or overrides configuration denials. Ordinary permission checks still apply after a progress assessment. History is bounded, explicitly truncated, and excludes private reasoning; the pending call is marked as not executed. Checks have their own cost and audit record, not messages in the working conversation. The separate 200-round turn ceiling is unchanged. Configure the limits in Permissions.

What a denial looks like to the model

A denied call returns an error result naming the reason: denied by the user, denied by config pattern exec(rm) args(*), or, in a script with no one to ask, no one is present to approve this call; run heph interactively, or pass --yes for Level 2 tools. The model reads it as a failed call and decides what to do next; often it asks you, or explains what it would have needed.