Design note: approvals for outward calls¶
Status: built (2026-10-21), off by default (BOSUN_APPROVALS_ENABLED=0). Tracked in
ROADMAP.md.
Why¶
The orchestrator's settings have ask rules for Slack sends, reactions and canvases,
gh pr merge|ready|edit|comment|review|close|update-branch, gh api and git push. Other MCP tools
with a write verb in their name (Jira and Confluence writes, CI builds) get an ask from the settings
file's own bosun guard hook. Either ask beats auto mode, so each such call opens a Claude Code
permission prompt.
In the first week of use that was over a hundred prompts, nearly all a second approval of something
I had already approved in chat ("1 and 3 yes"). And a prompt waits forever: two approved re-review
messages sat at their prompt overnight and kept the morning restart from running.
Claude Code behaviour this relies on (verified on 2.1.306, headless)¶
- A PreToolUse hook's
allowdoes not override a settingsask(ordeny) rule; it only replaces the default prompt when no rule matches. A PreToolUsedenyalways wins. - A PermissionRequest hook answering
{"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}}approves a call anaskrule (or a PreToolUseask) would prompt for, with no prompt."behavior": "deny"with amessagedenies it, and the model sees the message. - Both hooks get
session_id,transcript_path,cwd,permission_mode,tool_nameandtool_input. PreToolUse also getstool_use_id; PermissionRequest does not (2.1.306). - A PreToolUse
ask(as the plain guard gives MCP writes) still leads to the PermissionRequest hook. With several PreToolUse hooks,denyfrom any one wins (documented behaviour). - Unanswered prompts wait indefinitely; team-plan mods do not bypass
askrules.
Shape¶
The ask rules stay, as the backstop: if a hook fails, times out or gives no decision, the prompt
appears as before. With the flag on, bosun launch adds these hooks to the orchestrator (on
top of its settings file, whose own bosun guard hook stays):
| Hook | Mode | Decides |
|---|---|---|
| PreToolUse | bosun guard --approvals |
a gated call that carries out no approved proposal and no standing class: deny, with how to propose it. Else no decision |
| PermissionRequest | bosun guard --permission-request |
a gated call that carries out an approved proposal, or a standing class: allow, and use the approval up. Else no decision (the prompt) |
| PostToolUse, PostToolUseFailure | bosun guard --post-tool-use |
nothing: settles the proposal the call used (section 4) |
The gated calls are one table in lib/guard.py (GATED_MCP, GATED_MCP_FAMILIES,
GATED_COMMANDS). Its Slack tools and commands are the ones the ask rules name; its Jira,
Confluence and CI build tools have no rule and get their prompt from the plain guard (WRITE_VERBS).
A site adds its own command prefixes with
BOSUN_APPROVALS_GATED_COMMANDS (and an ask rule for each). Bash commands are matched by
their leading words anywhere in a compound command (gh pr view 1 && gh pr comment ... is gated).
CI build tools (restart_build, create_build, promote_build, stop_build) are gated too, so
an approved re-run goes through the PermissionRequest hook instead of the plain guard's prompt.
Flag off means no change¶
With BOSUN_APPROVALS_ENABLED=0 launch adds no approvals hooks. The compaction and signal hooks
the orchestrator also gets have flags of their own (BOSUN_COMPACT_CONTEXT, BOSUN_HOOK_SIGNALS).
Both new hook modes check the flag again on every call and decide nothing, so
turning it off works at once. The plain bosun guard (the settings file's PreToolUse hook) is
untouched. ledger propose without the new options and every other ledger command behave as
before.
1. Approved in chat: no second prompt¶
A proposal can carry an action spec, the exact call it stands for:
bosun ledger propose '<id>' --send slack --channel C0000000001 [--thread 1700000000.000100] --text - <<'EOF'
<the exact message>
EOF
bosun ledger propose '<id>' --run 'gh pr merge https://github.com/acme/exporter/pull/7 --squash'
bosun ledger propose '<id>' --tool jira_transition_issue --args-json - <<'EOF'
{"issue_key": "EXP-12", "transition_id": "31"}
EOF
The action line (shown in listings) defaults to a description of the spec. Free-text proposals still work; they just never let a call through.
When I approve, the orchestrator records it with my words:
bosun ledger approve '<id>' --quote '1 and 3 yes'
stored on the proposal with its time and a hash of the spec. The PermissionRequest hook allows a call only when all of these hold:
- The call matches an approved, unused proposal exactly. Slack: the same channel, the same
thread (or none), no
reply_broadcast, and the same text after collapsing runs of whitespace (case and punctuation count). A command: the same string, trimmed. Another MCP tool: the same tool name (under any server) and equal arguments. - The approval is for this version. Its hash matches the spec; a new proposal drops it.
- My words are mine. The quote appears verbatim (whitespace-normalized, case-insensitive) in a
prompt of the calling session's transcript (
transcript_path) made between the proposal and the approval (plus two minutes). Only prompts the session received from its user count, read throughclaude_state.delivered_prompts: typed, sent from the phone (Remote Control), queued while busy, or delivered into a running turn. Remote Control prompts appear in the transcript as ordinary user turns (verified on 2026-10-20 transcripts: aqueue-operationenqueue, then auserentry with the text as a string), so they count like typed ones; no special case. Never tool results, meta entries, the summary after/compactor task notifications; and of those prompts, never whatactivity.is_minerejects (bosun's own prompts:[bosun ...],Ledger item ..., check-in and drain prompts, slash commands, textbosun sendpasted) nor anything that opens like a tag (<cross-session-message>,<agent-message>, ...). - Once. Allowing it marks the approval used (
approval-usedin the item's history). A retry, or the same call again, needs a new proposal and approval. This holds even if the call then fails (Slack down, a merge conflict): a used approval never lets a second call through. Decided on 2026-10-21 to keep it that way; the orchestrator proposes the retry and I approve it again. The failure is recorded (approval-failed, section 4), so the proposal is back under Needs you.
So a forged approval needs my words in my prompts: text from Slack, a PR or another session that reaches the orchestrator as a tool result or an injected message never counts.
2. Standing approvals¶
BOSUN_STANDING_CLASSES lists the classes a site allows without a chat approval (none by
default). Each allowed call is recorded (auto-approved) and the chores list them under Handled
automatically (bosun ledger approvals --auto --since <scan start>).
pr-review-traffic: a Slack message (slack_send_message, not scheduled, not broadcast) when
- its channel is in
BOSUN_STANDING_SLACK_CHANNELS, never a DM (D...channels and user ids are dropped from the list); - it mentions only people in
BOSUN_STANDING_SLACK_MENTIONS, and no group (<!here>,<!subteam^...>,@channel); - it has at most one URL, and that is the parent PR's (
https://github.com/<repo>/pull/<n>); - it is exactly the proposed message (spec) of an open outreach item, not yet sent under a standing
approval, with channel slack and purpose
review,info-ackorfyi; - its parent is my PR (
mypr:) in the ledger, and either it is ready or, forfyionly, it is merged.fyioutreach may now be recorded on a merged PR for this.
Ready (approvals.pr_ready): the PR is open, and the latest report on its work after the
last hand-over says CI is green and leaves nothing open. "Its work" is the PR item and any item
that produces it (the task: a new-work window reported on). If a hand-over (delegate,
--update, a move) is newer than every report, it is not ready until the window reports again. The
ledger state alone (awaiting-review) is not evidence: the ledger cannot see CI, and the ready
check does not call GitHub. The repo window records CI with its report: bosun ledger report <id> ... --ci
green|red|pending|none (fix-my-pr and new-work tell it to wait for gh pr checks --watch first;
_common.md says what each value means). ledger reports shows it as a ci: line.
pr-housekeeping: one plain command (no chains, pipes, redirections, substitutions or
variables) on my open, ready PR, given by URL:
gh pr ready <url>(no flags), on a ready PR as above (CI green);gh pr edit <url>with only--title,--bodyand--add-reviewerfromBOSUN_STANDING_GH_REVIEWERS;gh pr comment <url> --body '<text>'(or-b, or--body=<text>, and nothing else) when<text>is exactly one ofBOSUN_STANDING_GH_COMMENTS('|'-separated; empty: none), e.g. the command that asks an automated reviewer for another round. At mostBOSUN_STANDING_GH_COMMENTS_MAX_PER_PR(default 3; 0: no cap) comments with that text go out on one PR under the standing approval, counted from the item'sauto-approvedhistory entries, which record the text (comment); past the cap each needs my word, so a loop that keeps asking for another round stops at a person. Such a comment is a re-review request, so the live-PR check below applies to it.
No class covers DMs, canvases, reactions, merges, closes, other comments, reviews, gh api, git
push or tracker writes: those need my word every time.
3. Never hang on an unapproved outward call¶
The PreToolUse hook denies a gated call that matches neither, with a reason that says why (no
proposal has exactly this action; proposed but not approved; approval used; quote not found) and
what to do: propose the exact call, list it under Needs your approval, and after I approve run
ledger approve --quote and retry the same call. A Bash call that holds an approved command with
more around it (timeout 120 <command>, a pipe, another step) is denied with the exact command to
run as a call of its own. The guard judges the command a wrapper runs (timeout, env, nice,
nohup, xargs and the others in WRAPPERS, lib/guard.py); before 2026-10-23 a wrapper hid it,
so such calls skipped the guard and waited at Claude Code's prompt. The orchestrator gets the denial
at once instead of waiting at a prompt until I come back. BOSUN_APPROVALS_DENY_UNAPPROVED=0 restores the old
prompt for such calls (read on every call, no restart needed).
4. After the call: close what was carried out¶
Before this, nothing closed a proposal once its approved call had run: outreach closed with ledger
sent, merges by hand, but four Jira comment proposals whose calls had succeeded stayed pending for
hours, and the console counted them as waiting on me. bosun guard --post-tool-use runs on
PostToolUse and PostToolUseFailure (same matcher, flag and gated-call table as the other hooks).
What the events carry (verified on 2.1.306, headless, a scratch hook dumping its input, a gated Bash command and a stdio MCP test server):
- PostToolUse fires only on success:
session_id,transcript_path,cwd,prompt_id,permission_mode,hook_event_name,tool_name,tool_input,tool_use_id,duration_ms,tool_response; MCP calls addmcp_server(name,source).tool_responsefor Bash is an object (stdout,stderr,interrupted,isImage,noOutputExpected; no exit code); for an MCP tool it is the result's content list ([{"type": "text", "text": ...}]). - A failure fires PostToolUseFailure instead, with no
tool_responsebuterror(a Bash exit 1:"Exit code 1"; an MCP result withisError: true: its text) andis_interrupt. A matcher for PostToolUse alone never sees failures.
Matching the call. PermissionRequest has no tool_use_id, so using an approval records, on it
(or on auto_approved), the call's hash (approvals.call_hash: the command for Bash, the tool and
arguments otherwise), the session and the transcript path. The post hook settles the open item whose
used, unsettled approval has the same hash, the same session (when both are known), and was used
at most CALL_WINDOW (30 minutes) before; if two items proposed the same call, the earliest use.
Nothing else is touched: no other item, no proposal without a used approval, nothing once settled.
Settling it (ledger.settle_call):
- success: the proposal is resolved
done, "carried out by the approved call ()". Outreach (and learn:items) keep their own closing command: the approval is markedcarried_out(approval-carried-outin the history) andledger sentrecords the ref and resolves it, as before; no double close. - failure (PostToolUseFailure, or a response with
is_error, a non-zero exit code orinterrupted): the proposal stays open, the approval is markedfailedand the history getsapproval-failedwith the reason and that a new approval is needed. - a Bash call sent to the background only started: nothing is recorded.
The hook reads the ledger first and takes the write lock only on a match (checking again under it), always exits 0 with no output, and with the flag off does nothing.
Where a proposal stands for me (approvals.proposal_status, used by the console's Needs you
and Ledger groups, ledger list and ledger.md):
| Proposal | Status | Needs me? |
|---|---|---|
| no approval, or one for an earlier version | awaiting your approval | yes |
| approved, not used | approved, not yet made (bosun makes the call) | no |
used, no result yet, or carried out (outreach waiting for sent) |
carried out, closing | no |
used, then approval-failed |
the approved call failed; needs a new approval | yes |
An outreach item in proposed waits on me only while its proposal does.
5. A re-review request must match the live PR¶
A message asking a reviewer for another look says their review is addressed. Once, such a bump was
written after the first review round, carried into a proposal after a second round had arrived
(three new threads), regenerated from the same old text the next morning and sent: approved and
standing approvals alike only check that the call is the proposed one, and the ledger cannot see a
review that came after the last report. So with BOSUN_APPROVALS_CHECK_REVIEW_THREADS=1 (the
default; read on every call) the hooks check the live PR before such a call goes out:
- Which calls: an allowed call (approved in chat or standing) that carries out the proposal of
outreach with purpose
reviewabout a PR (its parentmypr:item, else a PR URL in its url, subject or details),gh pr edit ... --add-revieweron amypr:item, orgh pr commenton amypr:item with one of the standing comment texts (BOSUN_STANDING_GH_COMMENTS). Nothing else touches the network. - The check (
guard.check_bump, overlib/pr_threads.py, read-only GraphQL throughghwithinBUMP_CHECK_SECONDS, 20 s, under the hooks' 30 s timeout): no review thread awaits the author (unresolved, last comment not by the PR's author), and no review by someone else was submitted after the proposal was recorded (proposed_at; the author's own thread replies are reviews too and do not count). Bots never count either way (GraphQL type Bot, or a login ending in[bot]): a review or AI bot that comments on every push would otherwise deny every legitimate bump, so its threads never await the author and its reviews are never newer. The exception is a reviewer bot the site relies on (BOSUN_REVIEWER_BOTS, its logins): its threads await the author and its reviews can be newer, like a person's, so a re-review request waits until its comments are answered. Its review is never an approval (pr_threads.approvals). - It disagrees:
--approvalsdenies the call, naming the threads (up to three URLs), the newer review and the threads per round, and what to do: runbosun pr-threads <url> --awaiting-author, hand those threads to the PR's window, then write and propose the message again from that output.--permission-requestdecides nothing, so the approval is not used up. - GitHub cannot be read (gh fails, times out, or is missing):
--approvalsanswersaskwith the error and--permission-requestdecides nothing, so Claude Code's prompt asks me. This follows how the hooks fail elsewhere (a hook that cannot decide leaves the prompt), and neither sends the message unchecked nor blocks it until GitHub is back. ledger sentis not checked: it records a message that already went out.
The PermissionRequest hook reads the PR again rather than trusting the PreToolUse result: the two hooks share no state, and a review can land in between. That is one more read-only GraphQL call per re-review request, about a second.
Cleanup. ledger resolve-carried-out [--dry-run] [--transcript PATH ...] settles proposals
whose approval was used without a result recorded (calls made before this hook): it looks for the
exact proposed call within five minutes of the use in the transcript recorded at use, else the
given paths, else the orchestrator's transcripts written since, and settles it by the tool
result's is_error. Without such evidence it lists the item for resolving by hand and changes
nothing.
Repo sessions¶
Repo sessions on Claude Code run with BOSUN_CLAUDE_FLAGS (by default --permission-mode auto
--chrome). Auto mode prompts only after blocking several actions in a row, and bypass mode never does,
so they rarely hang on these calls (a week of real transcripts in bypass mode had no permission prompt
in a repo session other than questions; the watchdog's waiting check reports the rest). Repo sessions
on Codex run with BOSUN_CODEX_FLAGS, whose automatic reviewer decides without a prompt
(harnesses.md). A site that runs Claude Code sessions in a prompting
mode could get the same pair of hooks through repo_session_settings (which only Claude Code windows
get), behind the same flag; the
quote check would then read the repo window's own transcript, and pasted_hashes for that window.
Not wired.
Cost¶
Each gated call runs the hook three times (PreToolUse, PermissionRequest, and PostToolUse or
PostToolUseFailure), and with the flag on the PreToolUse and post hooks run for every Bash and MCP
call: 0.15 to 0.4 s each, mostly load_config; the post hook reads the ledger only for a gated call. The quote
check reads the session's transcript (tens of MB late in a day) only for a call that matches an
approved proposal.
Decisions (2026-10-21) and what to revisit¶
- CI evidence comes from the window's report (
--ci green), not from the hook calling GitHub: the hook stays fast, offline and deterministic over the ledger, and the window that pushed is the one that watched the checks. The one exception is the re-review check (section 5): what it needs (a review newer than the report) is by definition not in the ledger, so it reads the PR, only for re-review requests, and asks me when it cannot. - The standing classes stay as strict as described above. The replay of a week of history
showed what they leave to me: re-review bumps sent while the item showed a newer hand-over than
its report, and notes about other people's PRs in the PR channel. Revisit after a week of real
bosun ledger approvals --autodata, before widening anything. - Phone approvals need no special case (Remote Control prompts are ordinary user turns, above).
- An approval is used even if its call fails (above, "Once"); the failure puts the proposal back under Needs you (section 4).