Permission Rules¶
bnerd's AI surfaces (TUI chat, bnerd web, bnerd mcp-server) decide whether a tool call is allowed, needs confirmation, or is blocked using a small rule-based permission engine. The three safety modes (--ai-mode read-only|non-destructive|full) are built-in presets of this engine, and an optional --ai-permissions profile file lets you fine-tune the rules for a single tool or shell command pattern without giving up the mode's guardrails.
How the engine decides¶
A rule set is a list of (permission, pattern, action) triples, evaluated last-match-wins: the engine walks the list in order and the last rule that matches a request decides the outcome. Later rules override earlier ones, regardless of how specific they are.
permissionmatches either the tool's name (e.g.shell_exec,kube_delete) or its safety class (read,write,destructive).pattern(optional) additionally matches the request's subject. Today the only tool that has a subject isshell_exec, whose subject is the shell command being run — so a pattern only makes sense written asshell_exec(some pattern).actionis one ofallow,ask, ordeny.
* in either permission or pattern matches any run of characters, including / and spaces. Matching is case-sensitive.
ask means "prompt the user to confirm" in the TUI and bnerd web chat. bnerd mcp-server has no interactive prompt to show, so there ask resolves to execute — treat ask as "allowed, but would have asked a human if one were watching." deny still blocks unconditionally on every surface, including MCP.
A separate, stricter gate sits on top of this for tools tagged RequiresConfirmation (deletes, key rotation): bnerd mcp-server hides them entirely regardless of what the permission engine decides, unless it is started with --allow-destructive (see Safety Modes → Confirmation-gated tools). That flag is what actually restores the "ask resolves to execute" behavior for those tools; without it, they are simply absent from the tool list.
The three modes are presets¶
--ai-mode compiles down to one of three built-in rule lists. They are byte-identical in behavior to the legacy hard-coded safety-mode checks they replaced:
| Mode | Rules | read | write | destructive |
|---|---|---|---|---|
read-only (default) | * deny, read allow | allow | deny | deny |
non-destructive | * deny, read allow, write ask | allow | ask | deny |
full | * ask, read allow | allow | ask | ask |
Profile files (--ai-permissions)¶
A profile is a JSON file of allow / ask / deny entry lists that overlays the active mode's preset. It doesn't replace the mode — it adds rules on top of it, and under last-match-wins the profile's rules take priority over the preset's because they're appended after it.
Two JSON shapes are accepted:
{
"permissions": {
"allow": ["shell_exec(git status*)", "shell_exec(git diff*)"],
"ask": ["kube_scale", "kube_rollout_restart"],
"deny": ["shell_exec(git push*)", "kube_delete", "kube_drain", "helm_uninstall"]
}
}
The wrapped form is the same shape used by mission's orchestrator/permissions/team-settings.json; unknown top-level keys (like a defaultMode or _comment field) are ignored. Either form accepts the same entries:
tool_name— matches the tool by name, e.g.kube_delete.tool_name(pattern)— matches the tool by name and the pattern against its subject, e.g.shell_exec(git push*). Only meaningful forshell_exectoday, since no other tool exposes a subject to match against.
Each entry must be non-empty and, if it contains (, must end with ) — an unterminated pattern (e.g. "shell_exec(git push") is rejected as a parse error. So is an entry with nothing before the pattern (e.g. "(git push*)"), which would otherwise compile to a rule that matches nothing at all.
Patterns match command text, not intent
A shell_exec(...) pattern is a prefix/glob match against the raw command string the model asked to run. It is not semantic containment, and it is trivially side-stepped by any command that reaches the same effect with different text:
- a wrapper shell —
sh -c "git push" - a leading space or extra whitespace —
" git push" - an absolute or aliased path —
/usr/bin/git push - chaining —
cd repo && git push,true; git push - an env prefix —
GIT_SSH_COMMAND=… git push - anything indirect — a script, a Makefile target,
ssh host '…'
Deny patterns stop the obvious shapes and are worth having, but do not treat "deny": ["shell_exec(git push*)"] as a guarantee that nothing will ever push. For real containment, run the AI under --isolate, which fences the filesystem at the OS level rather than by matching text — and prefer denying the tool ("deny": ["shell_exec"]) over trying to enumerate every dangerous command it could run.
Deny beats ask beats allow¶
Within a single profile, entries are compiled allow first, then ask, then deny — so deny always wins over ask, and ask always wins over allow, no matter what order the lists appear in the file. Putting a tool in both allow and deny is not a conflict you need to avoid; deny simply wins.
The mode stays the floor¶
A profile can only narrow or re-partition what the active --ai-mode preset already allows — it can never widen past it. This is enforced in two places in the code, independently of the permission rules: a tool whose safety class the mode forbids is refused when it is registered, and refused again when it is executed. Both checks ignore the profile entirely, so deny from the mode wins over any allow in a profile, and it still wins for tools that a surface registers after the profile was applied (the TUI and bnerd web both do this).
Concretely: putting "allow": ["kube_delete"] — or even "allow": ["*"] — in your profile file has no effect while --ai-mode is read-only. kube_delete is a destructive tool, so it is never registered, and would be blocked at execution even if it were.
If you want more than a mode's preset allows, raise the mode (--ai-mode non-destructive or --ai-mode full) and use the profile to restrict further, not the other way around.
Invalid or unreadable profile files fail closed
If --ai-permissions points at a file that doesn't exist, can't be read, or fails to parse (invalid JSON, an entry with an unterminated (pattern), or a file with no allow/ask/deny entries at all), the surface prints a warning and continues running on the mode's preset alone — the profile is simply not applied. This is a fail-closed fallback: a broken profile file can only leave you with the mode's stock behavior, never grant tools it shouldn't.
Where profiles apply¶
--ai-permissions (config key ai-permissions, env BNERD_AI_PERMISSIONS) is read by:
- the TUI's AI chat
bnerd webbnerd mcp-server
Each surface applies the profile once, right after its own tool registry is built for the configured --ai-mode.
Deny rules now reach subagents, teammates, and skill tools — with one remaining gap
A profile still applies directly only to the registry of the surface it was loaded into. What used to be a hard gap — a profile's rules not reaching anything the session delegates to — is now mostly closed:
- Subagents and teammates inherit your profile's deny rules.
sub_agent,explore_agent, andkube_agenteach still build their own registry (hard-coded toread-only), and a teammate spawned viabnerd team runor a team started from the TUI conversation still builds its own registry at the team's effective safety mode — but that child registry is now restricted against the launching session's own--ai-permissionsprofile denies before it ever runs:permission.Restrictappends your profile'sdenyrules on top of the child's rules, so under last-match-wins a deny you wrote always wins there too. Onlydenyis inherited — neveralloworask— so a delegation tree can only narrow as it fans out, never widen. Note precisely what's inherited: it's the operator's own profile's deny rules, not the active--ai-modepreset's blanket deny — the mode already governs the child independently, through its own floor (see below), not through this inheritance. Inheritance is transitive: a teammate whose role setssubagents: truepasses your denies on to the subagents it spawns, so they hold at every depth, not just the first hop. - Skill-provided tools are evaluated against their base tool too. When a skill wraps a real tool (its
ToolDef.BaseToolnames the wrapped tool), a permission decision now checks BOTH the skill tool's own name and the base tool's name, and takes the stricter of the two outcomes (deny>ask>allow). So"deny": ["shell_exec"]now also blocks a skill tool that wrapsshell_execunder a different name, and a pattern rule like"shell_exec(git push*)"reaches it too, as long as the skill passes the real command through as an ordinary parameter the AI supplies. That check follows exactly one level, so a skill may not wrap another skill's wrapper — bnerd refuses to build such a tool, since the middle link would hide the real tool's name from your rules. - Remaining gap: commands hidden in a skill's Defaults. The base-tool evaluation matches a pattern rule against the wrapper's own call subject (e.g. its own
commandparameter). If a skill's Defaults embed the actual command into a fixed value that the AI never supplies as a runtime parameter, there is nothing for a base-tool pattern rule to match against — it will not see that command. Denying the wrapper tool outright (by name) still works in that case; only pattern-based rules are affected.
What still holds everywhere, as before, is class and mode gating: every tool — subagent, teammate, or skill-wrapped — goes through the same Register/Execute mode floor as any other tool, so nothing can exceed the session's --ai-mode regardless of any of the above. If a specific command or tool must never run under any circumstances, prefer denying the tool by name over enumerating patterns, and use --isolate for containment that doesn't depend on matching text at all.
See Agent Profiles for how a role's own permission: block fits into this composition, including a class-wide deny pitfall specific to roles.
See also Safety Modes for how modes gate the MCP server's tool listing, and the AI isolation guide for the orthogonal filesystem sandbox (--isolate).