Repository navigation
The Approval Bridge
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
Unless your keypad answer reaches step 7, the hook returns no answer, and the prompt keeps waiting on screen.
scripts/install-claude-hook.sh # add the hook to ~/.claude/settings.json
scripts/install-claude-hook.sh --uninstall # take it out againThe 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.
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.
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.
-
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.
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.
A Write, Edit, MultiEdit, or NotebookEdit is eligible only inside
APPROVAL_ROOTS:
- If it's blank, the roots are
PROJECTS_ROOTplus every project inPROJECTS. - A
PROJECTS_ROOTthat doesn't exist contains nothing. -
DATA_DIR/workspaceis 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.
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.
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.
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 --enableThe 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.
| 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.
User documentation for Keryx.
For the threat model, see SECURITY.md;
for settings, the coding agents, and the tools, see docs/.
Apache-2.0, Copyright 2026 William Koch.
Setting up
Running it
Security and data
Tools and advanced features