diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index 7b91e5f..5ffe611 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 9aa1356..5b838cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/DESIGN.md b/DESIGN.md index 27abacb..a3a2242 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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**. @@ -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 diff --git a/README.md b/README.md index cc69db1..ae9ee69 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 4431193..192e84e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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; diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 7c56f4e..f96efcd 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -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 diff --git a/docs/diagrams/src/who-sees-the-secret.py b/docs/diagrams/src/who-sees-the-secret.py new file mode 100644 index 0000000..143c5ee --- /dev/null +++ b/docs/diagrams/src/who-sees-the-secret.py @@ -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']) diff --git a/docs/diagrams/who-sees-the-secret.png b/docs/diagrams/who-sees-the-secret.png new file mode 100644 index 0000000..41473d6 Binary files /dev/null and b/docs/diagrams/who-sees-the-secret.png differ diff --git a/docs/diagrams/who-sees-the-secret.svg b/docs/diagrams/who-sees-the-secret.svg new file mode 100644 index 0000000..57ca931 --- /dev/null +++ b/docs/diagrams/who-sees-the-secret.svg @@ -0,0 +1,1400 @@ + + + + + + + + 2025-01-01T00:00:00+00:00 + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +