From 2ac670181801600062eb7385132ce3467f8d644e Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:21:01 +0200 Subject: [PATCH 1/7] docs: say the secret goes to the command, not to the agent Refs #159 --- .claude/agents/security-reviewer.md | 9 ++++++--- AGENTS.md | 9 +++++++-- DESIGN.md | 14 +++++++++----- SECURITY.md | 3 ++- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index 7b91e5f..29670ca 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -5,9 +5,12 @@ 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 shows the secret to the agent: 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). 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..d853354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,12 @@ # 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) +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 shows the +secret to the agent: 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 +(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. diff --git a/DESIGN.md b/DESIGN.md index 27abacb..798aacd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,10 +3,14 @@ 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 (section 2, goal 3). When the agent needs one, it asks. The person gets a Telegram message with buttons. Nothing happens until the person taps **Approve**. No answer means **no**. @@ -31,9 +35,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/SECURITY.md b/SECURITY.md index 4431193..7b2e8b5 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; +- 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; From 3660a299cf9c365e9f7737087e06a28a366c9606 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:21:53 +0200 Subject: [PATCH 2/7] docs: add the who-sees-the-secret picture Refs #159 --- docs/diagrams/src/who-sees-the-secret.py | 40 + docs/diagrams/who-sees-the-secret.png | Bin 0 -> 23912 bytes docs/diagrams/who-sees-the-secret.svg | 1400 ++++++++++++++++++++++ 3 files changed, 1440 insertions(+) create mode 100644 docs/diagrams/src/who-sees-the-secret.py create mode 100644 docs/diagrams/who-sees-the-secret.png create mode 100644 docs/diagrams/who-sees-the-secret.svg 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 0000000000000000000000000000000000000000..41473d61405405eb2e591d8ec2b79e7e1705c560 GIT binary patch literal 23912 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 + + + + + + + 2025-01-01T00:00:00+00:00 + image/svg+xml + + + Matplotlib v3.11.1, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f7e30d6c4336a36152bcfc5dd8ab0a5173d36410 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:22:25 +0200 Subject: [PATCH 3/7] docs(readme): say who sees the secret, and the limit next to it Refs #159 --- README.md | 77 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index cc69db1..8e254aa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,24 @@ # 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. +- SSH keys stay on the broker. The client gets only signatures. +- **The limit:** 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. + +![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. The agent gets 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 +27,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 (see the limit 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 +56,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 value goes only into that one command's environment. nodkey never - prints it, but the command itself can (see the warning above). +- 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. The agent + sees only 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 +97,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. After you approve, the second terminal shows only +`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 +276,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 From 7d8de9754b1acc8366f69f04380b99424c647734 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:23:17 +0200 Subject: [PATCH 4/7] docs: point to the limit, and state it in the reviewer brief Refs #159 --- .claude/agents/security-reviewer.md | 9 +++++---- AGENTS.md | 4 ++-- DESIGN.md | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index 29670ca..cd40658 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -7,10 +7,11 @@ model: opus 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 shows the secret to the agent: 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). A mistake here leaks a secret or lets an agent act without consent. +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 (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 d853354..5bdb422 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # Working on 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. nodkey never shows the -secret to the agent: the value goes only into the environment of the one +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 diff --git a/DESIGN.md b/DESIGN.md index 798aacd..3236b5f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,7 +10,7 @@ The keys and the access to the secret stores live on a trusted machine. The 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 (section 2, goal 3). When the agent needs one, it asks. The person gets a +value ([THREAT-MODEL.md section 5](THREAT-MODEL.md#5-what-nodkey-does-not-promise)). When the agent needs one, it asks. The person gets a Telegram message with buttons. Nothing happens until the person taps **Approve**. No answer means **no**. From d48a665a9aeca3f0cdea9de7935548c0d0d996f9 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:25:05 +0200 Subject: [PATCH 5/7] docs: state the second limit: the agent can read the command's environment Refs #159 --- .claude/agents/security-reviewer.md | 4 +++- AGENTS.md | 13 +++++++------ DESIGN.md | 3 ++- README.md | 24 ++++++++++++++---------- SECURITY.md | 4 ++-- THREAT-MODEL.md | 5 +++-- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index cd40658..5ffe611 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -11,7 +11,9 @@ 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 (THREAT-MODEL.md section 5). A mistake here leaks a secret or lets an agent act without consent. +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 5bdb422..5b838cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,13 @@ 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 -(THREAT-MODEL.md section 5). Read [DESIGN.md](DESIGN.md) -and [THREAT-MODEL.md](THREAT-MODEL.md) before you change code. They are 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 3236b5f..33bd75f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,7 +10,8 @@ The keys and the access to the secret stores live on a trusted machine. The 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 ([THREAT-MODEL.md section 5](THREAT-MODEL.md#5-what-nodkey-does-not-promise)). When the agent needs one, it asks. The person gets a +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 one, it asks. The person gets a Telegram message with buttons. Nothing happens until the person taps **Approve**. No answer means **no**. diff --git a/README.md b/README.md index 8e254aa..1812c64 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,16 @@ command, not to the agent.** `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. + never puts it into the agent's chat. nodkey shows the agent only the + command's output. - SSH keys stay on the broker. The client gets only signatures. -- **The limit:** the agent chooses the command. A bad agent can write +- **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. + 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 @@ -29,8 +33,8 @@ stays on the broker.](docs/diagrams/who-sees-the-secret.svg) > approve.** It makes the agent ask before each use, and it keeps the > 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 (see the limit above), or use that SSH login for other -> things (it is a shell). +> 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 @@ -61,8 +65,8 @@ Try it on a test setup first. See the 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. The agent - sees only the command's output. nodkey never prints the value, but the + The value goes only into that one command's environment. nodkey shows + the agent only 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 @@ -100,8 +104,8 @@ nodkey try run --secret DEMO_TOKEN=demo-token -- sh -c 'echo "got ${#DEMO_TOKEN} 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. After you approve, the second terminal shows only -`got N characters`, never the value. +denied. After you approve, the second terminal shows the short code +and `got N characters`, never the value. Now see the limit. Run a command that prints the value: diff --git a/SECURITY.md b/SECURITY.md index 7b2e8b5..192e84e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,8 +20,8 @@ Anything that breaks a promise in protection described in [section 6](THREAT-MODEL.md#6-threats-and-answers), for example: -- a command the agent runs gets a secret, or the agent gets 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..31b406b 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -72,8 +72,9 @@ 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 + 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. From 5b4224121a66cf68ab51f4f715cee8b033bd85ab Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:25:11 +0200 Subject: [PATCH 6/7] docs(design): fix a broken reference in the opening Refs #159 --- DESIGN.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index 33bd75f..a3a2242 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,7 +11,9 @@ 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 one, it asks. The person gets a +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**. From 2abaf33d7e8d82b1b22e9fb00dc0d5bf938802c6 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:26:23 +0200 Subject: [PATCH 7/7] docs: exact wording for what the agent sees Refs #159 --- README.md | 16 ++++++++-------- THREAT-MODEL.md | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1812c64..ae9ee69 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ command, not to the agent.** `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. nodkey shows the agent only the - command's output. + 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 @@ -20,8 +20,8 @@ command, not to the agent.** ![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. The agent gets only the -command's output. For SSH, the broker sends only a signature; the key +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] @@ -65,8 +65,8 @@ Try it on a test setup first. See the 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 shows - the agent only the command's output. nodkey never prints the value, but the + The value goes only into that one command's environment. nodkey never + 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 @@ -104,8 +104,8 @@ nodkey try run --secret DEMO_TOKEN=demo-token -- sh -c 'echo "got ${#DEMO_TOKEN} 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. After you approve, the second terminal shows the short code -and `got N characters`, never the value. +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: diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 31b406b..f96efcd 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -74,10 +74,11 @@ 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. 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. + 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