Settings and permissions
The settings layers and how they merge, the five permission modes, the rule grammar, and shell hooks.
The layers
Settings are JSONC — comments and trailing commas are fine — and they stack. Lowest priority first (ADR-0003):
~/.bingo/settings.json— yours, everywhere<cwd>/.bingo/settings.json— the project’s, committed<cwd>/.bingo/settings.local.json— the project’s, yours alone--settings <path>— one more file above all three- the command-line flags, as a synthetic top layer
A missing file is skipped. A file whose root is not an object is an error, not a shrug.
How keys merge
The kernel owns five top-level keys — provider, model, thinking,
maxTokens, models. Every other key belongs to the plugin that claims it,
and the claim carries the rule by which it merges:
| rule | behaviour |
|---|---|
Replace | the higher layer wins (the default) |
Accumulate | lists concatenate, lowest layer first, the first copy of a repeat kept |
ByName | lists and maps of objects keyed by name or id; a higher entry replaces the lower in place |
Objects merge field by field at every depth; the rule applies at the leaves. An
explicit null in a higher layer clears the value from every layer below it,
which is how you unsay something a lower layer said.
A key nobody claims is not silently ignored: bingo reports it at startup as an
UNKNOWN_SETTING notice naming the layer that set it, so permisions is
caught the first time you run.
The keys plugins claim
| key | owner | merge |
|---|---|---|
permissions.defaultMode | permissions | replace |
permissions.allow / .deny / .ask | permissions | accumulate |
permissions.additionalDirectories | permissions | accumulate |
hooks.<Event> | shell hooks | accumulate |
mcpServers | mcp | by name |
disabledMcpServers | mcp | accumulate |
anthropic, openai, codex | the provider plugins | replace |
context.memory | context | replace |
web.search, web.braveApiKey | web tools | replace |
channels | channels | by name |
plugins | the cross-process bridge | by name |
demoUi | the demo plugin | replace |
A plugin types its own slice, so adding a key needs no kernel change and no merge function to keep in sync.
Permission modes
A mode says what happens when no rule decides.
| mode | when no rule decides |
|---|---|
default | trusted read-only tools run; everything else asks |
acceptEdits | edits inside the working directories run without a prompt |
plan | nothing that is not read-only runs at all |
bypassPermissions | everything runs except what only a person may decide |
dontAsk | nobody is there to answer, so what would have asked is denied |
Set one for a run with --permission-mode <mode>, for a session with
/permission <mode>, or as the floor with permissions.defaultMode. In the
terminal surface shift+tab cycles them, and the mode is the left slot of the
status line. --dangerously-skip-permissions is exactly
--permission-mode bypassPermissions.
A mode chosen with /permission lives in memory for that session and is never
written to a file.
Rules
Three tables — allow, deny, ask — matched against what a tool says it
will touch. One rule per line:
{
"permissions": {
"allow": ["Bash(git status:*)", "Read(/src/**)"],
"deny": ["Bash(rm:*)", "WebFetch(domain:internal.example.com)"],
"ask": ["Edit"]
}
}
The grammar:
| form | matches |
|---|---|
Tool | every call of that tool |
Tool(*) | the same — a rule that names nothing narrows nothing |
Tool(text) | a prefix of a command, a path or a url; the exact name of a Name subject |
Tool(text:*) | text as a prefix in every subject kind |
Tool(prefix:text) | the same, with prefix: stripped |
Tool(/src/**) | a path glob, where * stops at a separator |
Tool(domain:host) | the url host, exactly |
mcp__server | every tool of that server |
mcp__server__tool | that one tool |
plugin__name__tool | a tool from a cross-process plugin |
deny and ask read a rule the broad way — one hit is enough. allow reads
it the narrow way: every subject, and every sub-command inside a shell line,
must be covered. Each table takes the reading that fails closed.
--allowed-tools 'Bash(git status:*)' adds allow rules for one run. It is
repeatable, and one flag takes a comma-separated list.
What a tool is trusted with
A tool declares whether it is read-only, whether it is safe to run beside
another, and what an interrupt should do to it. Unknown tools fail closed:
a tool bingo does not know is not concurrency-safe, not read-only, and its
interrupt behaviour is to block. Everything reaching bingo from outside the
binary — an MCP server’s tools, a cross-process plugin’s tools — wears exactly
those defaults, whatever it says about itself. readOnlyHint is a claim, never
a fact.
Hooks
bingo-hooks-shell runs your own commands at bingo’s lifecycle points, on
Claude Code’s hook contract: the event arrives as JSON on stdin, a verdict
leaves as JSON on stdout, and the exit code decides — 0 is “here is what I
have to say”, 2 blocks with the hook’s own words as the reason, and anything
else is a broken hook that never gets to decide.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "./scripts/guard.sh" }]
}
]
}
}
Ten events have a bingo lifecycle point behind them: PreToolUse,
PostToolUse, PostToolUseFailure, PermissionRequest, UserPromptSubmit,
Stop, PreCompact, SessionStart, SessionEnd, Notification. An event
name outside that list is a startup failure — a hook nobody will run is a rule
its author believes is enforced. Only type: "command" hooks exist here;
other types are refused at startup rather than skipped in silence.
Two departures worth knowing:
- Timeouts are 60 seconds, and 1.5 seconds for
SessionEnd. A per-hooktimeoutoverrides that, still capped forSessionEnd. permissionDecision: "allow"does not skip the gate. bingo has one permission path — the policy — and a hook is not it.allowreads as “no objection”, and the call still goes to the gate. A hook can tighten what happens; it can never widen it.
A SessionStart hook may append KEY=value lines to the path in
BINGO_ENV_FILE, and every later hook in that session runs with them. The file
is read as assignments, not sourced as shell.
Hooks can also come from a cross-process plugin, on a typed schema and a long-lived connection rather than a fork per event — see cross-process plugins.