Permissions
[permissions] says which tool calls run without asking and which never run. It
can only narrow what a tool's level allows: nothing here makes a tool quieter
than its level (Permissions explains the levels).
[permissions]
# Level 2 calls that run without a prompt.
approve = [
"exec(cargo) args(*)",
"exec(git) args(diff *)",
]
# Never allowed, at any level. The model receives matching patterns and reasons.
deny = [
"exec(git) args(push *)",
"exec(rm) args(*)",
{ pattern = "edit(Cargo.lock)", reason = "Use the package manager to update the lock file." },
]
# Written by heph when you press A on a prompt. Matches like `approve`.
promoted = [
"exec(cargo) args(clippy *)",
]
| Key | Meaning |
|---|---|
approve | Patterns whose matching Level 2 calls run unasked. |
deny | Patterns whose matching calls are refused, at any level, before anything else is considered. |
promoted | Patterns heph appends when you answer A to a prompt. They match exactly like approve; the separate list keeps what you wrote apart from what heph wrote. |
Project lists are added to the user's, never replace them. A team's deny list
in .heph/config.toml binds every member on top of their own.
web_fetch is the one tool whose approvals are not patterns: a fetch is allowed
by its host, in [network]. A web_fetch(...) pattern here still
matches the URL as text, which is what deny = ["web_fetch"] is for; to allow a
host, add it to [network] allow.
Authored deny reasons
A deny entry is a pattern string or a table with a required string pattern and
an optional string reason. Both forms may coexist; a table without a reason is
equivalent to the string. Unknown fields, missing or invalid patterns, and
nonstring fields are config errors. approve and promoted still accept only
strings, as does [network].deny for hosts.
[permissions]
deny = [
"exec(rm) args(*)",
{ pattern = "edit(generated/**)", reason = "Edit the source schema and regenerate instead." },
{ pattern = "edit(generated/api/**)", reason = "API bindings come from schemas/api.yaml." },
]
Editing generated/api/client.rs returns both matching patterns and their
reasons to the model, and shows the same explanation in developer status. There
is no permission prompt and nothing executes. Every matching entry is included
in array order, user rules first and project rules second, even when patterns
repeat. A project cannot replace a user's reason or remove a user denial.
Reasons are plain text, with no interpolation or shell expansion. Empty or
whitespace-only reasons add no explanation; nonblank text and line breaks are
preserved, with continuation lines indented under Reason:. They reach the
selected model only when the rule denies a call, as ordinary tool-result
feedback, not higher-priority instructions. Avoid secrets, since normal tool
results may be retained. Suggested alternatives still require permission.
Typed serialization emits reasonless rules as strings and reason-bearing rules as tables, without promising exact lexical formatting. Approval promotion leaves untouched deny entries, reasons, and comments intact.
Pattern syntax
A pattern is tool(glob), a bare tool, or exec(program) args(glob).
-
toolis a tool name from the catalog: lowercase letters, digits, underscores. -
globmatches the call's principal parameter: the command forshell, the URL forweb_fetch, the path forview,ls,glob,grep,stat,edit,create,search_replace, andblame. Tools with no principal (status,branches,ask_user) match only as a bare name. -
A bare
toolmatches every call of the tool. -
*matches any run of characters. For the path tools it stops at/;**crosses directories. For URLs*matches anything, slashes included. -
?matches one character.
No wildcards in a shell command
A shell (and background) pattern is an exact command, with no * or
?. A shell call is a string run through sh -c, so a wildcard in the
pattern would cover the shell metacharacters that chain a second command:
shell(cargo *) would match cargo x; rm -rf ~, approving a command it never
meant to. A pattern with a wildcard for shell or background is a
configuration error.
exec pins the program and globs the arguments
To approve a program with a wildcard, use exec, which runs one named program
directly -- never through a shell -- with its arguments passed to it literally.
The pattern is exec(program) args(glob): the program is exact (no wildcard),
the args glob matches the arguments joined by spaces. exec(cargo) args(*)
approves any cargo invocation and only cargo; nothing an argument contains
can start a second process, because there is no shell to interpret it.
exec(program) with no args(...) matches only a call with no arguments.
Examples:
| Pattern | Matches |
|---|---|
exec(cargo) args(*) | Any cargo invocation, any arguments, run directly |
exec(cargo) args(test *) | cargo whose arguments begin test |
exec(make) | make with no arguments |
shell(make release) | Exactly the command make release, run through a shell |
web_fetch | Every fetch |
edit(src/*) | Files directly in src/, not in subdirectories |
edit(src/**) | Anything under src/ |
edit(**/*.lock) | Any lock file at any depth |
view(.env) | That one file (in deny, keeps secrets out of the context) |
An exec call may also set child-only environment variables with its env map.
The prompt displays those overrides, but patterns match only the program and
arguments. For example, exec(cargo) args(*) covers cargo test both with and
without env: {"RUST_BACKTRACE": "1"}; no approval of the generic env
launcher is needed.
What the prompt offers
When a prompt opens, heph proposes a pattern to remember: for an exec call,
the program and args(*); for a path, its directory and *; for a shell
command, the command exactly, since no wildcard is safe there. A web_fetch
prompt proposes the URL's host instead (Network). e on the
prompt edits it before a (session) or A (written here). The = line under
the pattern spells out its meaning in plain words, generated from the pattern
itself:
| Pattern | Expansion |
|---|---|
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 |
shell(make release) | exactly the command "make release", and nothing else; a shell approval is one exact command, with no wildcards |
shell | every call of shell |
edit(src/*) | edit on any file directly under src/, not in subdirectories |
Repeated-call checkpoints
[permissions.repeat]
soft_limit = 3
hard_limit = 12
soft_limit controls assessment of suspected recent repetition. hard_limit
requires a human when unchanged outcomes or cycles persist. The defaults are 3
and 12, and 0 < soft_limit < hard_limit is required. These are not lifetime
call counts: productive edit/test iterations do not exhaust an allowance.
Each turn retains 32 completed attempts, including denials. Detection considers unchanged results, matching calls dominating at least half of the last eight activities, and recurring cycles of two to six operations. Content states and outcomes distinguish productive work from oscillation. A progress verdict clears soft suspicion; a new evidence pattern or a sufficiently long gap clears the old episode. Human approval clears the turn's current pattern history and allowances, not ordinary permissions.
Commands allow three completed attempts before the earliest assessment, so transient compilation failures have a small retry allowance. A job still running may be polled eight times unchanged before assessment; polling raises the effective soft and hard limits to at least 8 and 32. It is not permanently exempt.
A project [permissions.repeat] table replaces the user's pair as a unit;
omitted keys in that table take their built-in defaults. Without a project
table, the user's pair is retained. These limits refresh with live permission
policy, just like the pattern lists.
The progress-check role uses the selected profile's fast tier by default,
respecting tier overrides. Older custom layouts without this role use their
quick tier. With neither route, the checkpoint requires human approval. See
The repeated-call anomaly
for the prompt and renewal behavior.
Order of evaluation
For each call, in this order, the first that applies decides:
-
Any
denymatch refuses the call, with every matching rule's explanation. Otherwise, a[network] denyhost refusesweb_fetch. -
The hard repeated-call limit requires a human. At the soft limit, a fast model assesses progress; looping, uncertainty, or failure requires a human. A positive assessment clears only this repetition checkpoint, not policy.
-
Level 0 and 1 calls run.
-
An
approveorpromotedmatch runs a Level 2 call. -
A pattern approved with
athis session runs it. -
For
web_fetch, a host on the allowlist runs it at Level 1, and a host approved withathis session runs it. -
--allow-tools,--yes, or--yes-reallyruns it (--yes-reallyalone covers Level 3). -
Otherwise, the prompt, or a denial when no one is there.
Flags
The same patterns work on the command line, for one run:
heph exec --allow-tools 'exec(cargo) args(*)' 'fix the tests'