Skip to article
Browse chapters

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):

  1. ~/.bingo/settings.json — yours, everywhere
  2. <cwd>/.bingo/settings.json — the project’s, committed
  3. <cwd>/.bingo/settings.local.json — the project’s, yours alone
  4. --settings <path> — one more file above all three
  5. 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:

rulebehaviour
Replacethe higher layer wins (the default)
Accumulatelists concatenate, lowest layer first, the first copy of a repeat kept
ByNamelists 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

keyownermerge
permissions.defaultModepermissionsreplace
permissions.allow / .deny / .askpermissionsaccumulate
permissions.additionalDirectoriespermissionsaccumulate
hooks.<Event>shell hooksaccumulate
mcpServersmcpby name
disabledMcpServersmcpaccumulate
anthropic, openai, codexthe provider pluginsreplace
context.memorycontextreplace
web.search, web.braveApiKeyweb toolsreplace
channelschannelsby name
pluginsthe cross-process bridgeby name
demoUithe demo pluginreplace

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.

modewhen no rule decides
defaulttrusted read-only tools run; everything else asks
acceptEditsedits inside the working directories run without a prompt
plannothing that is not read-only runs at all
bypassPermissionseverything runs except what only a person may decide
dontAsknobody 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:

formmatches
Toolevery 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__serverevery tool of that server
mcp__server__toolthat one tool
plugin__name__toola 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-hook timeout overrides that, still capped for SessionEnd.
  • permissionDecision: "allow" does not skip the gate. bingo has one permission path — the policy — and a hook is not it. allow reads 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.