Skip to content

Security Model

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

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.

The basics

  • 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_CALLERS isn'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=true and PUBLIC_HOST is set, keryx serve refuses to start the phone channel, and keryx doctor reports 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.

What the tunnel exposes

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.

Three levels of trust

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

The PIN is the line between reading and acting

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.

Searching past calls needs the PIN

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.

Possession comes only from a call Keryx placed

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.

Voicemail can't act

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.

A call without the PIN can't change the memory

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.

Which tools need what

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.

Plugins and your own tools

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_pin is on by default. A tool that turns it off answers anyone who calls, so read what keryx tools says about each one.
  • Only you, at your own terminal, can install or remove a plugin. keryx plugins install and remove refuse to run inside a Keryx task. A task can still write into DATA_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 .toml is 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 in secrets.toml.
  • cluster_stats never 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 only squeue and sinfo.

The PIN

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 setup asks 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/pin only if that file doesn't exist yet, so a later call can't overwrite it.
  • After that, only keryx setup at 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.

Wrong PINs

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_slack plugin is on;
  • it sends a text, if SMS_ENABLED is 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.

What Keryx may change about itself

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.

Reporting a vulnerability

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.

Related

Clone this wiki locally