Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .claude/agents/security-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ tools: Read, Grep, Glob, Bash
model: opus
---

You review changes to nodkey, a tool that gives an AI coding agent a secret
or an SSH signature only after a person approves that one request on a
phone. A mistake here leaks a secret or lets an agent act without consent.
You review changes to nodkey, a tool that lets an AI coding agent use a
secret or an SSH key only after a person approves that one request on a
phone. nodkey never puts the secret into the agent's chat: the value
goes only into the environment of the one approved command, on the agent
machine, and an SSH private key never leaves the broker (the client gets
only a signature). The agent writes that command, so a bad command can
still print or send the value, and the agent can read it from the
command's environment while it runs (THREAT-MODEL.md section 5). A
mistake here leaks a secret or lets an agent act without consent.
Review like an attacker who controls the agent machine.

## Before you start
Expand Down
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Working on nodkey

nodkey gives an AI coding agent a secret or an SSH signature only after a
person approves that one request on a phone. Read [DESIGN.md](DESIGN.md)
and [THREAT-MODEL.md](THREAT-MODEL.md) before you change code. They are the
nodkey lets an AI coding agent use a secret or an SSH key only after a
person approves that one request on a phone. nodkey never puts the
secret into the agent's chat: the value goes only into the environment
of the one approved command, on the agent machine, and an SSH private
key never leaves the broker (the client gets only a signature). The
agent writes that command, so a bad command can still print or send the
value, and the agent can read it from the command's environment while
it runs (THREAT-MODEL.md section 5). Read [DESIGN.md](DESIGN.md) and
[THREAT-MODEL.md](THREAT-MODEL.md) before you change code. They are the
rules the code must follow.

## Layout
Expand Down
17 changes: 12 additions & 5 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
Status: accepted for version 1 on 2026-10-12.

nodkey lets an AI coding agent use a secret or an SSH key only after a
person approves that one request on a phone.
person approves that one request on a phone. The secret goes to the one
command that the agent runs, not to the agent.

The keys and the access to the secret stores live on a trusted machine. The
agent never holds them. When the agent needs one, it asks. The person gets a
agent never holds them. A secret value goes only into the environment of
one approved command, and nodkey never puts it into the agent's chat. The
agent writes that command, so a bad command can still print or send the
value, and the agent can read it from the command's environment while it
runs ([THREAT-MODEL.md section 5](THREAT-MODEL.md#5-what-nodkey-does-not-promise)).

When the agent needs a secret or a key, it asks. The person gets a
Telegram message with buttons. Nothing happens until the person taps
**Approve**. No answer means **no**.

Expand All @@ -31,9 +38,9 @@ what it protects against, and what it does not.

## 2. Goals

1. The agent can use a secret or an SSH key only when the approver tapped
**Approve** for that request, or a grant or a rule the approver wrote
allows it.
1. A command the agent runs gets a secret, and the agent gets an SSH
signature, only when the approver tapped **Approve** for that request,
or a grant or a rule the approver wrote allows it.
2. An SSH private key never leaves the broker. The broker signs; the client
only forwards.
3. nodkey never prints a secret value. A value goes only into the
Expand Down
79 changes: 61 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
# nodkey

nodkey lets an AI coding agent use a secret or an SSH key only after a
person approves that one request on a phone.
**An AI agent asks. You approve on your phone. The secret goes to one
command, not to the agent.**

- The agent writes a command, for example
`nodkey run --secret GH_TOKEN=github-token -- gh release create v1.2.0`.
- You see the request on Telegram and tap **Approve**.
- nodkey puts the value only into the environment of that command. It
never puts it into the agent's chat. The agent sees the command's
output and nodkey's own status lines, never the value from nodkey.
- SSH keys stay on the broker. The client gets only signatures.
- **The limits:** the agent chooses the command. A bad agent can write
one that prints or sends the secret, and an approved SSH login is a
shell. So read the command before you tap. And the command runs as the
agent's own user, so while it runs, a bad agent can also read the
value from the command's environment (`/proc/PID/environ`). So approve
only a secret that you accept this agent may get, and prefer short-lived
or narrow secrets.

![The agent writes the command. The nodkey client asks the broker. The
broker asks you on your phone. After you approve, the broker sends the
value only into the command's environment. nodkey gives the agent only
the command's output. For SSH, the broker sends only a signature; the key
stays on the broker.](docs/diagrams/who-sees-the-secret.svg)

> [!WARNING]
> **nodkey is work in progress.** It is new, it has had no outside
Expand All @@ -10,8 +31,10 @@ person approves that one request on a phone.
>
> **nodkey does not protect you from a malicious agent once you
> approve.** It makes the agent ask before each use, and it keeps the
> keys off the agent's machine. But once you approve a request, the agent
> can read or keep that secret, or use that SSH login for other things.
> keys off the agent's machine. But the agent writes the command, so once
> you approve a request, it can make that command print, save or send
> the secret, or read it while the command runs (see the limits above),
> or use that SSH login for other things (it is a shell).
> A tap helps only if you read the request well, and even then the
> command and the reason are only what the agent claims. Some ways to
> reach a secret skip nodkey: root on the broker host, a login to the
Expand All @@ -37,19 +60,24 @@ Try it on a test setup first. See the
SSH keys and the access to your secret stores (Bitwarden Secrets Manager
through `bws`, the Bitwarden Password Manager through `bw`, a vault of
1Password, or a project of Infisical).
- The agent's machine gets only the `nodkey` client. It holds no SSH key
and no store token. Its own TLS key lets it ask, not approve.
- When the agent needs a secret, it runs
`nodkey run --secret GH_TOKEN=github-token -- gh release create v1.2.0`.
- The agent's machine gets only the `nodkey` client. The agent never
gets an SSH key or a store token. The client's own TLS key lets it
ask, not approve.
- The agent writes this command, and nodkey runs it with the secret
added: `nodkey run --secret GH_TOKEN=github-token -- gh release create v1.2.0`.
The value goes only into that one command's environment. nodkey never
prints it, but the command itself can (see the warning above).
shows the value to the agent; the agent sees the command's output. nodkey never prints the value, but the
command itself can: the agent chose the command, so check it before
you tap.
- When `ssh`, `git` or Ansible need a signature, `nodkey ssh-agent` sends
the request to the broker. The key stays on the broker.
the request to the broker. The key stays on the broker; the client
gets only the signature.
- When a request needs approval, you get a Telegram message with
**Approve once** and **Deny**, and for some secrets and keys also
**Approve N min**. It shows which machine asks, for which secret or key,
and for SSH the real target host, checked by the broker. No answer
means no.
**Approve N min**. The broker checks which machine asks, for which
secret or key, and for SSH the real target host; the message shows
these as verified. The command and the reason in the message are only
what the agent claims. No answer means no.
In place of Telegram, the broker can use an ntfy server that you run
yourself ([docs/setup.md](docs/setup.md#ntfy-in-place-of-the-telegram-bot)).
- Every request and decision goes into a hash-chained audit log.
Expand All @@ -73,10 +101,24 @@ In a second terminal, run the command that it prints:
nodkey try run --secret DEMO_TOKEN=demo-token -- sh -c 'echo "got ${#DEMO_TOKEN} characters"'
```

The request shows up in the first terminal. Type `1` (Approve once), `2`
(Approve 5 min) or `3` (Deny), then Enter. Or wait 2 minutes: the
request expires and is denied. Press Ctrl-C to end the trial; it removes
its files.
The second terminal plays the agent's screen. The request shows up in
the first terminal. Type `1` (Approve once), `2` (Approve 5 min) or `3`
(Deny), then Enter. Or wait 2 minutes: the request expires and is
denied. While the request waits, the second terminal shows its short
code. After you approve, it shows `got N characters`, never the value.

Now see the limit. Run a command that prints the value:

```sh
nodkey try run --secret DEMO_TOKEN=demo-token -- sh -c 'echo "$DEMO_TOKEN"'
```

Approve it, and the fake value shows up in the second terminal. This is
the limit: the command can print the secret, and the agent writes the
command. So you read the command before you tap.

Press Ctrl-C in the first terminal to end the trial; it removes its
files.

Try mode is not a setup. The agent could type the answer in that
terminal, so the trial has only fake values (they start with `TRY-`) and
Expand Down Expand Up @@ -238,7 +280,8 @@ root on the broker host on the agent machine

A Telegram message comes, with the same short code that the client
prints. Tap **Approve once**. The command gets the secret in
`GH_TOKEN`, and prints only its length.
`GH_TOKEN`, and prints only its length, so the agent's screen shows
only that length.

Before real use, read
[docs/setup.md](docs/setup.md): above all the safety of your Telegram
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ Anything that breaks a promise in
protection described in
[section 6](THREAT-MODEL.md#6-threats-and-answers), for example:

- the agent gets a secret or an SSH signature without an approval;
- the agent, or a command the agent runs, gets a secret, or the agent
gets an SSH signature, without an approval;
- a secret value shows up in a log, an error, the audit log or a Telegram
message;
- a tap approves a different request than the one shown;
Expand Down
12 changes: 7 additions & 5 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ access to another machine and asks a person before each use.
Read this part carefully. These are real limits.

1. **After approval, the secret is on the agent machine.** It is in the
environment of the command. A hostile agent with root can read it from
that process. It can also ask for it with a command that prints it. The
message shows the command, but the command is claimed and a script can
hide what it does. nodkey limits *when* and *which* secret; it does not
stop a hostile agent from keeping a secret it was given.
environment of the command. The command runs as the agent's user, so a
hostile agent can read it from that process while it runs (for example
from `/proc/PID/environ`); it needs no root. It can also ask for it with
a command that prints it. The message shows the command, but the
command is claimed and a script can hide what it does. nodkey limits
*when* and *which* secret; it does not stop a hostile agent from
keeping a secret it was given.
2. **After an approved SSH login, the agent has a shell on that host.** It
can do anything that user can do there. It can keep the connection open
and reuse it without new taps (`ControlMaster`, `ControlPersist`). It can
Expand Down
40 changes: 40 additions & 0 deletions docs/diagrams/src/who-sees-the-secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Who sees the secret: the source of docs/diagrams/who-sees-the-secret.svg
# and .png. Render it with a Schemdraw run that sets DRAW_OUT to the output
# path, for example: DRAW_OUT=out.svg python3 who-sees-the-secret.py
import os

import schemdraw
from schemdraw import flow

schemdraw.config(fontsize=12)

W, H = 4.4, 2.6 # the size of a box
GAP = 5.0 # the space between two boxes

with schemdraw.Drawing(show=False) as d:
agent = flow.Box(w=W, h=H).at((0, 0)).anchor('center').label(
'Agent (chat)\n\nnodkey puts\nno value here')
cmd = flow.Box(w=W + 0.6, h=H).at((W + GAP + 0.3, 0)).anchor('center').label(
'nodkey client\n+ the command\n\na bad command\ncan print it')
broker = flow.Box(w=W, h=H).at((2 * (W + GAP) + 0.6, 0)).anchor('center').label(
'Broker\n\nkeys and\nstore access')
phone = flow.Box(w=W - 0.6, h=H).at((3 * (W + GAP) + 0.3, 0)).anchor('center').label(
'Your phone\n\nyou read the\ncommand and tap')

up, dn = 0.45, -0.45
flow.Arrow().at((agent.E.x, up)).to((cmd.W.x, up)).label('1. writes\nthe command', 'top')
flow.Arrow().at((cmd.W.x, dn)).to((agent.E.x, dn)).label('6. output only', 'bottom')
flow.Arrow().at((cmd.E.x, up)).to((broker.W.x, up)).label('2. asks', 'top')
flow.Arrow().at((broker.W.x, dn)).to((cmd.E.x, dn)).label(
'5. the value, only into\nthe command\'s environment', 'bottom')
flow.Arrow().at((broker.E.x, up)).to((phone.W.x, up)).label('3. asks you', 'top')
flow.Arrow().at((phone.W.x, dn)).to((broker.E.x, dn)).label('4. Approve', 'bottom')

# SSH: the key stays on the broker; the client gets only a signature.
y = -H / 2 - 1.6
flow.Line().at(broker.S).to((broker.S.x, y))
flow.Arrow().at((broker.S.x, y)).to((cmd.S.x, y)).label(
'SSH: a signature only, the key stays on the broker', 'bottom')
flow.Arrow().at((cmd.S.x, y)).to(cmd.S)

d.save(os.environ['DRAW_OUT'])
Binary file added docs/diagrams/who-sees-the-secret.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading