Skip to content

The Approval Bridge

William Koch edited this page Sep 30, 2026 · 12 revisions

The approval bridge lets you answer by phone when Claude Code is waiting for you and you're away from the keyboard. After five minutes, Keryx calls you, reads the question out, and takes your answer from the keypad.

It's optional, and stays off until you install its hook. If the bridge fails, the original prompt stays on screen and nothing happens.

sequenceDiagram
    autonumber
    participant CC as Claude Code
    participant H as The hook
    participant J as Keryx
    participant P as Your phone

    CC->>H: about to put a prompt on screen
    H->>J: hands it over, then blocks
    Note over CC,H: the prompt is drawn exactly as it always was
    alt you answer at the keyboard
        CC-->>H: PostToolUse / Stop
        H-->>CC: prints nothing — the keyboard always wins
    else five minutes pass
        J->>J: is it eligible? allowlist, denylist,<br/>quiet hours, rate limit
        J->>P: rings, reads it out, offers a menu
        P->>J: one keypad digit
        J->>J: is it still pending?
        J-->>H: the verdict
        H-->>CC: allow or deny
    end
Loading

Unless your keypad answer reaches step 7, the hook returns no answer, and the prompt keeps waiting on screen.

Installing it

scripts/install-claude-hook.sh              # add the hook to ~/.claude/settings.json
scripts/install-claude-hook.sh --uninstall  # take it out again

The installer copies scripts/claude_hooks/keryx_approval.py into ~/.claude/hooks/. It then merges the hook entries into ~/.claude/settings.json. It backs that file up first, and leaves any hooks you already have alone. If you keep the Claude CLI's files elsewhere with CLAUDE_CONFIG_DIR, it uses that directory instead of ~/.claude.

The installer writes STATE_DIR, as the service sees it (keryx config path), into the hook's command. If you move STATE_DIR, run the installer again. Nothing else is needed: APPROVALS_ENABLED is on by default, and Keryx opens the socket when it starts.

The installer copies the hook instead of linking to it, so edits in a working checkout can't change a running session. Run the installer again after you pull changes to scripts/claude_hooks/. Keryx refuses requests from an outdated hook that doesn't report whether it trimmed the request. It logs a warning, and the original prompt keeps waiting on screen.

Why a Unix socket, not an HTTP route

cloudflared puts all of port 8080 on the public internet. An /approvals endpoint would be reachable by anyone who found the hostname, and it would be the one endpoint that can run commands. Instead, the bridge listens on approvals.sock under STATE_DIR, with mode 0600. The tunnel can't reach a Unix socket at all. And its only legitimate client is a process that already runs as you, so file permissions are the right way to control access.

Command policy

When the hook answers allow, the Claude CLI appears to skip its own check of permissions.deny. So Keryx's own command policy is the main control on what a keypad can approve. It's a small allowlist, plus a denylist for things such as .env, .ssh/, sudo, curl, and strings that look like keys. Anything not explicitly allowed stays on screen for you to approve by hand. Adding to the allowlist widens what the phone keypad can run. Write down the reason for every addition.

The policy protects you from untrusted text in issues, pull requests, or web pages that steers a Claude Code session while you're away. It makes sure that what you hear on the phone is the whole command that will run.

It decides on exactly what will run, or not at all

  • The raw command. Control characters are refused before anything is normalized: newline, carriage return, NUL, tab, the other C0 and C1 controls, and DEL. So is invisible or line-breaking Unicode, and so are &, |, ;, a backtick, $(, >, and <, quoted or not. If the command were normalized first, a newline could become a space. It would be read out as one harmless line, and run as two commands.
  • The arguments the shell will build. The command is parsed with a small subset of shell syntax that bash and zsh agree on: plain words, 'single quotes', and "double quotes" with nothing inside that the shell would expand. A $variable, a glob, ~, {a,b}, a backslash, #, a leading =, or '' is refused rather than guessed at.
  • The whole request. Before sending a request, the hook trims every string to 4096 characters, so a file's contents never cross the socket. It also says whether it had to trim. A trimmed request is never eligible, because the CLI runs the original, and the part past the cut is exactly where something could hide.
  • Read back whole. If the spoken sentence for an approval would be longer than 180 characters, the approval is refused, not shortened. A question can still be shortened, because answering one runs nothing. But an option label longer than 40 characters is refused, because the label is the answer Claude receives.

What the default allows

APPROVAL_BASH_ALLOW defaults to git push,git commit. Each entry matches a command word for word. Only these two entries accept arguments, and only the arguments in this table. Options must be spelled in full, because git reads --mirr as --mirror:

Command May carry Refused, among everything else
git push -u/--set-upstream, -q/--quiet, -v/--verbose, -n/--dry-run; a remote name; plain branch or tag names a URL, host:path or path as the remote; +ref (force); :ref (delete); src:dst; globs; --force*, -f, --delete/-d, --mirror, --all, --tags, --prune, --repo, --receive-pack/--exec, -o/--push-option, --no-verify
git commit -m/--message, -a/--all, -q/--quiet; pathspecs -F/--file and -t/--template (they read a file from anywhere into a commit a push then sends away), -n/--no-verify (skips the hooks that check a commit), --amend, -C/-c, --author, --pathspec-from-file

Any other command you add runs exactly as you wrote it, or not at all: make test doesn't allow make test DESTDIR=/.

If a remote name isn't a configured remote, git reads it as a directory next to the repository, so the push stays on your machine. The policy reads no files, so it doesn't look in .git/config.

No test runner by default. A test run executes the working tree: every test file, conftest.py, and any plugins and addopts your config names. uv run also syncs the project first, and may build it. The working tree is what the session writes all day, often without asking. "Claude wants to run: pytest" sounds harmless, but it tells you nothing about what the tests will do. You can still add pytest to APPROVAL_BASH_ALLOW yourself. It then matches exactly pytest.

A Bash request is never eligible if it sets dangerouslyDisableSandbox, or carries a field the policy doesn't know. You wouldn't hear either of them on the phone.

File writes

A Write, Edit, MultiEdit, or NotebookEdit is eligible only inside APPROVAL_ROOTS:

  • If it's blank, the roots are PROJECTS_ROOT plus every project in PROJECTS.
  • A PROJECTS_ROOT that doesn't exist contains nothing.
  • DATA_DIR/workspace is never a root.

The check uses the resolved path, so .. and symlinks can't lead outside a root. The file is read back with its path within the project, such as "create the file .github/workflows/deploy.yml, in myproject", not just deploy.yml.

A write is never eligible if any part of its path is .git, .claude, or .mcp.json (in any case). Git runs .git/hooks and obeys .git/config (core.fsmonitor, core.hooksPath, the remote's URL) on the very git commit and git push that a keypad may approve next. The Claude CLI applies a project's .claude/settings*.json hooks and permissions, and starts .mcp.json servers, on its own.

The one exception is .claude/worktrees/<name>/…, where Claude Code keeps its worktrees. The source files in a worktree are ordinary files. The worktree's own .git and .claude are still refused.

Keypad confirmation

Only a keypad press can approve a tool call. The voice model can read the question out and offer the menu, but nothing it hears or says can approve anything. A key the menu doesn't recognize makes it ask again, rather than approve. You can approve only on a call where you gave the PIN, or on a call Keryx placed to your number, such as the call about the prompt (see Security Model). To enter the PIN while an approval menu is waiting, press * to switch the keypad to PIN entry, then press * again to return to the menu.

Failure behavior

Every failure leaves the prompt unchanged on screen: Keryx not running, the socket missing, Twilio broken, the call unanswered, a malformed reply, or the hook crashing. An error never approves anything, and the keyboard always takes priority.

If an eligible prompt arrives while you're on a call that could answer it, Keryx announces it in that call instead of placing a second one. That's a call where you gave the PIN, or one Keryx placed to your number. Otherwise, it calls you separately.

The bridge checks that a prompt is still waiting before it calls, and again before it applies your answer. If you've already handled the prompt at the keyboard, the PostToolUse, PermissionDenied, Stop, and SessionEnd hook events cancel the call.

Turning it off

uv run keryx approvals              # the audit trail: what it escalated and what happened
uv run keryx approvals --limit 5
uv run keryx approvals --disable    # kill switch, effective on the next prompt
uv run keryx approvals --enable

The kill switch is a file that's checked on every request, so it works without a restart. keryx config set APPROVALS_ENABLED false turns the bridge off at startup instead. Keryx then doesn't open the approval socket at all, and the bridge behaves as if the hook had never been installed.

Tuning

Setting Default What it does
APPROVAL_ESCALATE_SECONDS 300 how long a prompt waits on screen before it rings
APPROVAL_CALL_WINDOW_SECONDS 240 how long the hook keeps waiting after that
APPROVAL_MAX_PER_HOUR 4 calls per hour. A bridge that rings ten times a day gets muted, and then misses the call that mattered.
APPROVAL_QUIET_HOURS unset HH:MM-HH:MM, local time: when it never rings at all
APPROVAL_BASH_ALLOW git push,git commit the only shell commands a keypad may run. Read what the default allows before adding one.
APPROVAL_ROOTS unset where a file may be written by phone; blank is PROJECTS_ROOT plus every project in PROJECTS

Quiet hours use the host's local time. See Assumptions and Deployments for what that means on a machine in another time zone.

Clone this wiki locally