Repository navigation
Security Model
Keryx takes requests by phone and runs them as you, with your user permissions. Who can
reach it, and what they can make it do, is the security question. For the full threat model
and what counts as a vulnerability, see
SECURITY.md.
- Tasks run as you. They run with your full user permissions and never stop to ask for approval. Codex runs without its sandbox. A task can reach your files, your repositories, and the network. So anyone who can make Keryx start a task can run commands on this machine.
-
Caller ID can be spoofed.
ALLOWED_CALLERSisn't authentication: anyone who spoofs an allowed number reaches the voice model. The PIN is what protects the phone. -
Signature validation can't be switched off behind the tunnel. If
DEBUG_SKIP_TWILIO_VALIDATION=trueandPUBLIC_HOSTis set,keryx serverefuses to start the phone channel, andkeryx doctorreports a hard failure. -
Secrets stay in files only you can read. Keys and tokens are in
~/.config/keryx/secrets.toml, with mode 0600 in a directory with mode 0700. Keep that directory out of any dotfiles repository. Secrets are never taken on a command line, never printed back, and never logged. Wherever a caller's number is written down, it's masked to its last four digits.
The tunnel exposes only these routes:
| Route | What protects it |
|---|---|
/twilio/voice |
Twilio's signature, and the caller must be in ALLOWED_CALLERS
|
/twilio/status |
Twilio's signature only, because it carries no caller to check |
/twilio/media |
A one-time stream token that /twilio/voice issues for the same call, valid for 60 seconds. A connection that doesn't present one within five seconds is closed. |
/reports/{id}?t=… |
A signed link. It's the link Keryx texts you when SMS_ENABLED is on. |
/health |
Nothing. It answers ok and the number of live sessions. |
The interactive API docs and /openapi.json are switched off, so the tunnel doesn't
publish a list of these routes.
Each call has one of three trust levels, and each tool needs a minimum level:
| Level | How a call gets it | What it adds |
|---|---|---|
NONE |
An inbound call, before the PIN | Reading: the standing briefing, and the tools that read it back |
POSSESSION |
A call Keryx placed to one of your own numbers | Answering the question a task came back with, a call back on that number, marking results as heard, and a keypad answer to a waiting approval |
FULL |
The PIN, given on this call | Everything: starting work, searching past calls, the Slack and email plugins, restarts, settings, and the memory update after the call |
With BRIEFING_BEFORE_PIN on (the default), a call starts out knowing what Keryx knows:
the results you haven't heard yet, the memory, your project names and briefs, and your
installed skills. Before the PIN, the call can also list your tasks and projects, and
read a task's status or result.
This means you don't need the PIN just to ask how things are going. The cost is that
someone who spoofs an allowed caller ID could hear that information. If you set
BRIEFING_BEFORE_PIN=false, the call gets none of the briefing and none of those tools
until the PIN is given.
Until a PIN exists, none of this is read aloud, whatever BRIEFING_BEFORE_PIN says.
The briefing is bounded and curated: keryx memory shows it, and you can prune it. A
search of past calls (recall) is different. It's an unbounded search over every
transcript Keryx has written, and the caller decides what to search for. That's why it
always needs the PIN.
When Keryx dials one of your numbers, it records that fact in the call's one-time stream token. That record is the only way a call gets possession. The caller ID and the numbers Twilio reports play no part.
Possession doesn't allow starting work, searching past calls, Slack, restarts, or a call back to a number chosen during the call. Those need the PIN.
An answering machine can pick up a call Keryx placed and hear a result, but that's all.
Before Keryx acts on anything said on a call at POSSESSION, it wants one keypress
earlier in the same call.
When an approval menu and the PIN both want the keypad, press * to switch to PIN
entry, and * again to switch back. * is never part of a PIN or a menu option. You can
always say the PIN out loud instead.
Keryx doesn't update its memory after a call that never gave the PIN. It keeps the transcript for auditing, but searches of past calls skip it.
Three built-in abilities work at every level, whatever the settings: web search, entering the PIN, and ending the call. Every other built-in tool is gated at one of the levels in the table.
Of the plugins, check_billing and cluster_stats answer before the PIN, because they
read a number and change nothing. send_to_slack and check_email wait for the PIN. A
tool of your own waits for the PIN unless its file says needs_pin=False.
Announcements follow the same rules. News goes to any call. Restart confirmations and
approval requests go only to a call at POSSESSION or above.
Every .py file in DATA_DIR/tools is code that Keryx imports inside the service at the
start of each call. That includes the four plugins and any tool of your own (see
Custom Tools). These rules guard them:
- A file or directory that anyone but you could write is refused unread, because loading a file runs its code.
- No tool can take a built-in tool's name, so nothing there can replace PIN entry or starting a task.
-
Each file decides who may hear it.
needs_pinis on by default. A tool that turns it off answers anyone who calls, so read whatkeryx toolssays about each one. -
Only you, at your own terminal, can install or remove a plugin.
keryx plugins installandremoverefuse to run inside a Keryx task. A task can still write intoDATA_DIR/tools, as it can write any file of yours. This is a rule for Keryx's own commands, not a sandbox. -
A plugin's settings can't smuggle anything in. Every value written into its
.tomlis validated and quoted first.cluster_stats's hosts and partitions must be plain words (letters, digits,_,., and-), because both reach a remote shell. A plugin's secrets aren't in that file at all: they stay insecrets.toml. -
cluster_statsnever opens a connection of its own. It first asks the local ssh ControlMaster socket whether a login is open. It then reads only over that open login, and never waits on a password prompt. If no login is open, it says that the login has expired, once, and doesn't retry. The remote command is built from fixed text and can run onlysqueueandsinfo.
You can say the PIN or key it in. Keyed digits never enter the model's transcript, and a
spoken PIN is written into the call log as [PIN]. A PIN must be 6 to 8 digits and
nothing else. Otherwise, keryx serve refuses to start, and keryx doctor says which
rule the PIN breaks.
The PIN isn't a setting, and nothing can change it once it exists:
-
keryx setupasks for a PIN at the keyboard. - If there's no PIN, the first call may key one in, twice. Keryx writes it to
~/.config/keryx/pinonly if that file doesn't exist yet, so a later call can't overwrite it. - After that, only
keryx setupat the keyboard can replace the PIN, after you confirm twice.
Whoever calls first sets the PIN. SECURITY.md explains this accepted risk and how to avoid it.
On a single call, three wrong entries end the call. After that, the call won't accept a PIN, not even the right one.
Across calls, Keryx counts every wrong PIN in DATA_DIR/pin-failures.json, which
survives a restart:
| Setting | Default | What it does |
|---|---|---|
PIN_FAILURE_LIMIT |
10 | wrong PINs, across all calls, before PIN entry locks |
PIN_FAILURE_WINDOW_HOURS |
24 | how long a wrong PIN is remembered |
PIN_LOCKOUT_MINUTES |
60 | how long PIN entry stays locked |
While PIN entry is locked, every PIN is refused, including the right one. Nothing resets the count early. Keryx tells you once:
- it says so in a call that has already given the PIN;
- it posts to Slack, if the
send_to_slackplugin is on; - it sends a text, if
SMS_ENABLEDis on.
Someone who can spoof your number can keep triggering the lockout, but they can't use it to get past the PIN. If the alert wasn't you, see PIN and brute force for what to do.
At most MAX_PHONE_SESSIONS calls (2 by default) run at once. This limits both parallel
guessing and call costs.
On a call after the PIN, you can ask Keryx to change a few of its own settings, such as
its voice, its turn-taking, and the model that does the work. A task that runs
keryx config set can do the same. Both may change only the settings the running service
is allowed to write.
Every credential, the PIN, who may call, BRIEFING_BEFORE_PIN, the approval allowlist,
spending, and retention are protected, and keryx config unlock can't free them. A
plugin's settings live in its own file (DATA_DIR/tools/<name>.toml), which a call can't
change.
This is a rule that Keryx's own tools obey, not a sandbox. A task runs as you and can edit any of your files.
The approval bridge is the only way a keypad press can make a command run. It has its own allowlist and its own kill switch, and any failure means "do nothing". Read its page before you install the hook.
These are in scope:
- any way for a caller without the PIN to act, change anything, or reach a task;
- anything that gives possession to a call Keryx didn't place.
A caller with the PIN effectively has a shell as you, and so does content that reaches a task you authorized. That's the design, not a vulnerability.
Don't open a public issue for a security problem. Use the repository's Security tab, then Report a vulnerability.
- Data and Retention — what's written to disk, what leaves the machine, and how to delete it.
- Assumptions and Deployments — the single-owner model.
-
Troubleshooting — what a PIN lockout, a busy line, and a refused
servelook like. -
SECURITY.md— the full threat model.
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