pi-robot-hand 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -17
- package/dist/apply.js +142 -0
- package/dist/placement.js +222 -0
- package/dist/shell.js +7 -0
- package/dist/write.js +66 -0
- package/lib/apply.ts +148 -0
- package/lib/placement.ts +227 -0
- package/lib/shell.ts +9 -0
- package/lib/write.ts +79 -0
- package/package.json +18 -5
- package/skills/secret-drop/SKILL.md +33 -0
- package/src/follow-up.ts +54 -0
- package/src/hand.ts +38 -9
- package/src/herdr.ts +159 -0
- package/src/index.ts +79 -8
- package/src/secret-drop/guard.ts +204 -0
- package/src/secret-drop/index.ts +355 -0
- package/src/secret-drop/legacy.ts +47 -0
- package/src/secret-drop/redact.ts +76 -0
- package/src/secret-drop/registry.ts +88 -0
- package/src/secret-drop/secret-input.ts +269 -0
- package/src/secret-drop/stage.ts +124 -0
package/README.md
CHANGED
|
@@ -1,16 +1,46 @@
|
|
|
1
1
|
# pi-robot-hand
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The lazy kit for [pi](https://pi.dev): too lazy to open an IDE, too lazy to copy and paste, not lazy enough to switch off the safety net.
|
|
4
|
+
|
|
5
|
+
When the agent cannot or should not run something itself (a `/reload`, a `sudo` command, an interactive login, a secret going into a config file), you still do not want to leave the conversation. pi-robot-hand moves the command or the secret to you in one keystroke and brings the result back to the agent, while the guards that keep the agent away from dangerous commands and secret files stay on:
|
|
6
|
+
|
|
7
|
+
- **`robot_hand`**: the agent hands you a command. Pick where it goes: your pi prompt, the clipboard, or a new Herdr pane. The agent never runs it.
|
|
8
|
+
- **`!` follow-up**: after a `!command` you type finishes, the agent reads its output and answers, without another message from you.
|
|
9
|
+
- **`secret_drop`**: you type a secret into a masked dialog and apply it with a `!` command you can read. The agent never sees the value.
|
|
10
|
+
|
|
11
|
+
You are the one who runs every command, so a command-guarding extension such as [cc-safety-net](https://github.com/kenryu42/cc-safety-net) keeps checking everything the agent itself runs.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-robot-hand
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The package ships three extensions and a `secret-drop` skill that teaches the agent to use `secret_drop` and to let programs consume secret files.
|
|
20
|
+
|
|
21
|
+
### Upgrading from pi-secret-drop
|
|
22
|
+
|
|
23
|
+
`secret_drop` used to be the separate `pi-secret-drop` package. Both packages register the same tool, which pi refuses to load, so while `pi-secret-drop` is still in your settings pi-robot-hand leaves `secret_drop` to it and shows a warning with the exact command to remove it:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi remove npm:pi-secret-drop@0.1.6 # the source as listed in your settings
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Protected destinations and their registry in `~/.pi/agent/secret-drop/` carry over unchanged.
|
|
30
|
+
|
|
31
|
+
## `robot_hand`: hand the user a command
|
|
4
32
|
|
|
5
33
|
1. The agent calls `robot_hand` with a command and a one-line reason.
|
|
6
|
-
2. Pi shows an inline selector in place of the prompt, with the reason and exactly what
|
|
7
|
-
3.
|
|
8
|
-
- **Put it in my prompt**: a shell command lands as `! command`, a pi slash command such as `/reload` lands as-is. Pressing Enter runs it; a `!` command's output returns to the conversation.
|
|
34
|
+
2. Pi shows an inline selector in place of the prompt, with the reason and exactly what you would run.
|
|
35
|
+
3. You pick one:
|
|
36
|
+
- **Put it in my prompt**: a shell command lands as `! command`, a pi slash command such as `/reload` lands as-is. Pressing Enter runs it; a `!` command's output returns to the conversation and the agent answers it.
|
|
9
37
|
- **Copy it to the clipboard**: the raw shell command, for another terminal.
|
|
38
|
+
- **Type it into a new Herdr pane (I press Enter)**: when pi runs inside [Herdr](https://herdr.dev), a pane opens below pi with the command on an editable line. Edit it if needed and press Enter, or Ctrl+C to cancel.
|
|
39
|
+
- **Run it in a new Herdr pane**: the same pane, with the command started at once.
|
|
10
40
|
- **No, thanks**: nothing is placed.
|
|
11
|
-
4. The tool returns the choice. After a
|
|
41
|
+
4. The tool returns the choice. After a hand-over the agent's turn ends, so the next move is yours.
|
|
12
42
|
|
|
13
|
-
|
|
43
|
+
A Herdr pane is a real terminal, so password prompts, `sudo`, confirmations, `ssh`, and full-screen programs work there; pi's `!` has no terminal input. When the command finishes, the pane closes and its output (the last 200 lines) and exit code arrive as a message that starts the agent's next turn. Cancelling, or closing the pane yourself, also reports back.
|
|
14
44
|
|
|
15
45
|
```
|
|
16
46
|
robot_hand 需要 sudo 重啟服務
|
|
@@ -20,38 +50,133 @@ The agent never runs the command. It suits commands the user has to run: pi slas
|
|
|
20
50
|
|
|
21
51
|
! sudo systemctl restart nginx
|
|
22
52
|
|
|
23
|
-
→
|
|
53
|
+
→ Type it into a new Herdr pane (I press Enter)
|
|
54
|
+
Put it in my prompt
|
|
24
55
|
Copy it to the clipboard
|
|
56
|
+
Run it in a new Herdr pane
|
|
25
57
|
No, thanks
|
|
26
58
|
|
|
27
59
|
↑↓ navigate enter select escape/ctrl+c cancel
|
|
28
60
|
──────────────────────────────────────────────
|
|
29
61
|
```
|
|
30
62
|
|
|
31
|
-
## Install
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
pi install npm:pi-robot-hand
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Tool: `robot_hand`
|
|
38
|
-
|
|
39
63
|
| Parameter | Description |
|
|
40
64
|
|---|---|
|
|
41
65
|
| `command` | The command. Shell commands go without a leading `!`; pi commands start with `/`. |
|
|
42
66
|
| `reason` | One short line telling the user why, in the user's language. |
|
|
43
67
|
| `kind` | `shell` (default) or `pi`. |
|
|
68
|
+
| `default` | The option the selector starts on: `prompt` (default), `clipboard`, `herdr-type`, or `herdr-run`. The agent picks `herdr-type` for commands that need terminal input. An option this session cannot offer falls back along `herdr-run` → `herdr-type` → `clipboard` → `prompt`. |
|
|
69
|
+
|
|
70
|
+
When the prompt already holds a draft, the prompt option says it replaces the draft. The clipboard and Herdr options appear only for shell commands in the terminal UI, and the Herdr options only inside Herdr. RPC clients get the prompt option; modes without a UI make the tool fail so the agent shows the command in chat.
|
|
71
|
+
|
|
72
|
+
The Herdr pane runs a short wrapper script (zsh, or bash 4+ when zsh is missing) from a private temporary directory, which is removed when the run is reported. The command runs in a non-interactive shell, so your shell aliases and functions are not loaded.
|
|
73
|
+
|
|
74
|
+
## `!` follow-up
|
|
44
75
|
|
|
45
|
-
|
|
76
|
+
Pi records the output of a `!command` but does not start a turn. With pi-robot-hand, once a `!command` typed while the agent is idle finishes, the agent reads its output and answers. Pi still runs the command, so output streams live and other `user_bash` extensions keep working. `!!` commands, cancelled commands, commands typed while the agent is busy, and commands overtaken by a prompt you send meanwhile keep pi's behavior.
|
|
77
|
+
|
|
78
|
+
## `secret_drop`: put a secret in without the agent seeing it
|
|
79
|
+
|
|
80
|
+
1. The agent calls `secret_drop` with a destination, a placement, and the steps to get the secret.
|
|
81
|
+
2. Pi opens a masked dialog that shows those steps, where the value will go, and what happens next. The user types or pastes the secret, or presses Tab to ask the agent a question instead.
|
|
82
|
+
3. The value is staged in `~/.pi/agent/secret-drop/staging/` (mode 600), and pi pre-fills a `!` command in the user's prompt:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
! node ~/.pi/agent/npm/node_modules/pi-robot-hand/dist/apply.js env ./.env DB_PASSWORD --from ~/.pi/agent/secret-drop/staging/64770-948e1cb74b5a
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
4. The user reviews it and presses Enter. The apply script writes the destination, deletes the staged file, and prints only a report:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
✓ secret-drop: updated DB_PASSWORD (1 line) in ./.env (env DB_PASSWORD, mode 644) — length check passed (28 chars)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
5. The tool, which has been waiting, returns that report and the agent continues.
|
|
95
|
+
|
|
96
|
+
The agent never opens the destination: the user runs the write with a command they can read. That also keeps secret-guarding extensions such as [cc-safety-net](https://github.com/kenryu42/cc-safety-net) in charge of the agent's tool calls, since pi's `!` commands are the user's own.
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
╭─ Secret Drop ──────────────────────────────────────────────────────────────╮
|
|
100
|
+
│ Staging database password │
|
|
101
|
+
│ │
|
|
102
|
+
│ How to get it │
|
|
103
|
+
│ 1. Open the staging project in 1Password │
|
|
104
|
+
│ 2. Copy the password of the "db-staging" item │
|
|
105
|
+
│ │
|
|
106
|
+
│ Goes to ./.env (env DB_PASSWORD) │
|
|
107
|
+
│ Next Enter here stages the value. Your prompt then holds a ! command; │
|
|
108
|
+
│ press Enter on it to apply. The agent never sees the value. │
|
|
109
|
+
│ │
|
|
110
|
+
│ › •••••••••••••••••••••••••••• │
|
|
111
|
+
│ 28 chars │
|
|
112
|
+
│ │
|
|
113
|
+
│ Enter stage · Tab ask a question instead · Esc cancel · Ctrl+R show/hide · │
|
|
114
|
+
│ Ctrl+U clear │
|
|
115
|
+
│ Stuck or unsure? Press Tab and ask; the dialog closes and the agent │
|
|
116
|
+
│ answers. │
|
|
117
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Tab opens a visible question field. Enter there closes the dialog without staging anything, discards what was typed in the secret field, and returns the question to the agent, which answers and calls `secret_drop` again. When the steps do not fit the terminal, the dialog shortens them; the tool call in the chat shows them in full.
|
|
121
|
+
|
|
122
|
+
### With cc-safety-net
|
|
123
|
+
|
|
124
|
+
The two cover different halves of the problem:
|
|
125
|
+
|
|
126
|
+
- **cc-safety-net keeps the agent out of secrets that already exist.** It blocks the agent's shell and file tools from reading `.env` files, SSH keys, `~/.aws`, and other well-known credential locations, whether or not secret_drop wrote them.
|
|
127
|
+
- **secret_drop gets new secrets in without the agent seeing them.** It protects the destinations it writes, including files outside cc-safety-net's patterns such as `config.yml`, and redacts the values from tool output.
|
|
128
|
+
|
|
129
|
+
They do not conflict. cc-safety-net inspects the agent's tool calls; the apply command is the user's own `!` command, so the write goes through while the agent stays blocked from reading the result.
|
|
130
|
+
|
|
131
|
+
### Parameters
|
|
132
|
+
|
|
133
|
+
| Parameter | Meaning |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `label` | What the user should enter, in the user's language. |
|
|
136
|
+
| `instructions` | Required. Numbered steps, in the user's language: where to go (URL or menu path), what to fill in, and what to copy. One step for a value the user already knows. |
|
|
137
|
+
| `url` | The page where the user creates or finds the secret. |
|
|
138
|
+
| `format` | `env`, `regex`, `file`, or `command`. Defaults to `env` when `key` is set, `command` when `command` is set, else `file`. |
|
|
139
|
+
| `destination` | `env` / `regex` / `file`: target file, relative to the working directory. Missing directories are created. |
|
|
140
|
+
| `key`, `quote` | `env`: variable name and quoting (`auto`, `none`, `single`, `double`). Existing `KEY=` / `export KEY=` lines are replaced, keeping trailing `# comments` and CRLF endings; otherwise the line is appended. Use `none` for files read by `docker --env-file`, which keeps quotes as part of the value. |
|
|
141
|
+
| `regex`, `flags` | `regex`: replaces capture group 1, or the whole match, in an existing file. `g` replaces every match. |
|
|
142
|
+
| `fileMode` | Octal permissions. New files default to `600`; existing files keep their mode. |
|
|
143
|
+
| `overwrite` | `file`: allow replacing an existing destination, for rotating single-value files such as keys. Without it an existing destination is refused; with it the dialog warns in red. |
|
|
144
|
+
| `command` | `command`: shell command reading the staged file `{secret}`, e.g. `ansible-vault encrypt_string --stdin-name db_password < {secret} >> vault.yml`. Stdout is discarded; stderr is shown with the value redacted when the command fails. |
|
|
145
|
+
|
|
146
|
+
Writes are atomic (temp file + rename) and verified by re-reading the destination. When verification fails, the original content and mode are restored from memory (a newly created file is removed); no backup file is written, so no copy of a secret is left outside the protected destination. The staged file is overwritten and deleted after every apply attempt, and when the tool is cancelled or aborted. Staged files left by pi processes that have exited are removed at session start.
|
|
147
|
+
|
|
148
|
+
### Protection after the write
|
|
149
|
+
|
|
150
|
+
- **Blocked reads.** `read`, `edit`, `write`, and `grep` on a destination or the staging area are blocked. In bash, each simple command is checked on its own: it is blocked when a protected path is an argument of a command that prints, copies, or opens files (`cat`, `head`, `grep`, `sed`, `cp`, `base64`, …, also behind `sudo`, `env`, `timeout`, `xargs`), a `<` or `>` redirection target, or inside `sh -c`, `eval`, `$(…)`, or backticks. Commands that only pass the path to a consumer, such as `--env-file .env` or `test -s .env`, still run.
|
|
151
|
+
- **Redaction.** Any tool result or context message containing a secret value is rewritten to `[REDACTED]`. Values are reloaded from protected destinations at session start.
|
|
152
|
+
- **Registry.** Protected locations (never values) live in `~/.pi/agent/secret-drop/registry.json`. `/secret-drop` lists them; `/secret-drop forget <path>` removes one.
|
|
153
|
+
|
|
154
|
+
### Limits
|
|
155
|
+
|
|
156
|
+
- Requires the interactive terminal UI. In RPC, JSON, and print modes the tool fails instead of falling back to a plain-text prompt.
|
|
157
|
+
- The dialog is a centered overlay, which pi-tui cannot draw over rows holding terminal images. Set `PI_ASK_USER_DISPLAY_MODE=inline` (the variable pi-ask-user reads) in the shell that launches pi to render it inline instead.
|
|
158
|
+
- The bash guard parses command lines, not programs. It stops accidental reads, not a determined agent: an indirect path (a variable, a glob, a script or `python -c` that prints the file) passes through. Redaction is the second layer for that output.
|
|
159
|
+
- The rename replaces the destination's inode: hard links to it break and the file becomes owned by the user who runs the apply command. Symlinks are resolved and kept.
|
|
160
|
+
- A multi-line quoted `KEY="…` value is replaced on its first line only.
|
|
161
|
+
- Values shorter than 4 characters are not redacted. Values applied with `format: "command"` are redacted for the rest of the session only.
|
|
162
|
+
- The apply command is shown before it runs; review it, since a `command` can send the staged value anywhere.
|
|
163
|
+
|
|
164
|
+
## `robot_hand` and CC Safety Net
|
|
165
|
+
|
|
166
|
+
CC Safety Net 2.4.7 scans tool input for sensitive paths, so it may block `robot_hand` when the *text* of a command names one. `robot_hand` only displays the command; the user decides whether to run it.
|
|
167
|
+
|
|
168
|
+
Use a CC Safety Net build containing the Pi `robot_hand` display-only exemption (the local `fix/pi-allow-display-only-tools` branch; **not** the published 2.4.7 release). It applies only to the exact Pi tool name `robot_hand`; `bash` and other tools stay protected. To check it, ask the agent to hand over a harmless command containing a dummy sensitive-path name, such as `.env.agent-8091`: the selector should open without a CC Safety Net block. Do not press Enter to run a command you do not want to execute.
|
|
46
169
|
|
|
47
170
|
## Development
|
|
48
171
|
|
|
49
172
|
```bash
|
|
50
173
|
npm install
|
|
51
174
|
npm run check # tsc --noEmit
|
|
52
|
-
npm test # node --test
|
|
175
|
+
npm test # builds dist/, then node --test
|
|
53
176
|
```
|
|
54
177
|
|
|
178
|
+
`lib/` holds the secret placement and write logic shared by the extension and the apply script. The extension imports it as TypeScript, so pi's `/reload` picks up an upgrade; Node caches `.js` and `.mjs` modules for the life of the process. `npm run build` compiles `lib/` to `dist/` for the apply script, which runs with plain `node` because Node does not strip TypeScript types under `node_modules`. `npm pack` and `npm publish` build first.
|
|
179
|
+
|
|
55
180
|
Releases publish from GitHub Actions through npm trusted publishing: bump `version` in `package.json`, commit, and push a matching `v<version>` tag.
|
|
56
181
|
|
|
57
182
|
## License
|
package/dist/apply.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a staged secret to its destination. The user runs this through a pi `!` command, so its
|
|
3
|
+
* output reaches the model: it reports what changed and a length check, never the value.
|
|
4
|
+
* Compiled to dist/apply.js, which the prefilled command runs with node.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { checkValue, describePlacement, ENV_QUOTES, validatePlacement } from "./placement.js";
|
|
10
|
+
import { shellQuote } from "./shell.js";
|
|
11
|
+
import { parseFileMode, readExisting, writeSecret } from "./write.js";
|
|
12
|
+
const USAGE = `usage:
|
|
13
|
+
apply.js env <destination> <KEY> --from <staged> [--quote auto|none|single|double] [--mode 600]
|
|
14
|
+
apply.js regex <destination> <regex> --from <staged> [--flags g] [--mode 600]
|
|
15
|
+
apply.js file <destination> --from <staged> [--overwrite] [--mode 600]
|
|
16
|
+
apply.js exec <command using {secret}> --from <staged>`;
|
|
17
|
+
/**
|
|
18
|
+
* Overwrite then delete the staged file.
|
|
19
|
+
*/
|
|
20
|
+
async function wipe(path) {
|
|
21
|
+
try {
|
|
22
|
+
const { size } = await stat(path);
|
|
23
|
+
const handle = await open(path, "r+");
|
|
24
|
+
try {
|
|
25
|
+
await handle.write(Buffer.alloc(size), 0, size, 0);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await handle.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Missing or unwritable: removal below still runs.
|
|
33
|
+
}
|
|
34
|
+
await rm(path, { force: true });
|
|
35
|
+
}
|
|
36
|
+
function redact(value, text) {
|
|
37
|
+
return value.length > 0 ? text.split(value).join("[REDACTED]") : text;
|
|
38
|
+
}
|
|
39
|
+
function chars(value) {
|
|
40
|
+
return [...value].length;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run a shell command with {secret} replaced by the staged file path. Stdout is discarded.
|
|
44
|
+
*/
|
|
45
|
+
async function runCommand(command, staged, value) {
|
|
46
|
+
if (!command.includes("{secret}"))
|
|
47
|
+
throw new Error("exec command must reference {secret}.");
|
|
48
|
+
const script = command.split("{secret}").join(shellQuote(staged));
|
|
49
|
+
const child = spawn("sh", ["-c", script], { stdio: ["ignore", "pipe", "pipe"] });
|
|
50
|
+
let stdoutBytes = 0;
|
|
51
|
+
let stderr = "";
|
|
52
|
+
child.stdout.on("data", (chunk) => {
|
|
53
|
+
stdoutBytes += chunk.length;
|
|
54
|
+
});
|
|
55
|
+
child.stderr.on("data", (chunk) => {
|
|
56
|
+
stderr += chunk.toString("utf8");
|
|
57
|
+
});
|
|
58
|
+
const code = await new Promise((resolve, reject) => {
|
|
59
|
+
child.on("error", reject);
|
|
60
|
+
child.on("close", resolve);
|
|
61
|
+
});
|
|
62
|
+
if (code !== 0) {
|
|
63
|
+
const tail = redact(value, stderr).trim().split("\n").slice(-15).join("\n");
|
|
64
|
+
throw new Error(`command exited ${code}${tail ? `:\n${tail}` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
const suppressed = stdoutBytes > 0 ? `; ${stdoutBytes} bytes of stdout suppressed` : "";
|
|
67
|
+
return `command exited 0 — secret delivered via {secret} (${chars(value)} chars)${suppressed}`;
|
|
68
|
+
}
|
|
69
|
+
async function apply(format, args, options, staged, value) {
|
|
70
|
+
if (format === "exec") {
|
|
71
|
+
if (args.length !== 1 || !args[0])
|
|
72
|
+
throw new Error(USAGE);
|
|
73
|
+
return runCommand(args[0], staged, value);
|
|
74
|
+
}
|
|
75
|
+
let placement;
|
|
76
|
+
const destination = args[0];
|
|
77
|
+
if (format === "env" && args.length === 2 && destination && args[1]) {
|
|
78
|
+
const quote = options.quote ?? "auto";
|
|
79
|
+
if (!ENV_QUOTES.includes(quote))
|
|
80
|
+
throw new Error(`Invalid quote "${quote}".`);
|
|
81
|
+
placement = { mode: "env", key: args[1], quote: quote };
|
|
82
|
+
}
|
|
83
|
+
else if (format === "regex" && args.length === 2 && destination && args[1]) {
|
|
84
|
+
placement = { mode: "regex", pattern: args[1], flags: options.flags ?? "" };
|
|
85
|
+
}
|
|
86
|
+
else if (format === "file" && args.length === 1 && destination) {
|
|
87
|
+
placement = { mode: "file" };
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
throw new Error(USAGE);
|
|
91
|
+
}
|
|
92
|
+
const fileMode = parseFileMode(options.mode);
|
|
93
|
+
const problem = checkValue(placement, value);
|
|
94
|
+
if (problem)
|
|
95
|
+
throw new Error(problem);
|
|
96
|
+
validatePlacement(placement, await readExisting(destination), options.overwrite === true);
|
|
97
|
+
const outcome = await writeSecret(destination, placement, value, fileMode);
|
|
98
|
+
const where = `${destination} (${describePlacement(placement)}, mode ${outcome.mode.toString(8)})`;
|
|
99
|
+
return `${outcome.summary} in ${where} — length check passed (${chars(value)} chars)`;
|
|
100
|
+
}
|
|
101
|
+
async function main() {
|
|
102
|
+
const { values, positionals } = parseArgs({
|
|
103
|
+
allowPositionals: true,
|
|
104
|
+
options: {
|
|
105
|
+
from: { type: "string" },
|
|
106
|
+
quote: { type: "string" },
|
|
107
|
+
flags: { type: "string" },
|
|
108
|
+
mode: { type: "string" },
|
|
109
|
+
overwrite: { type: "boolean" },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const staged = values.from;
|
|
113
|
+
if (!staged) {
|
|
114
|
+
console.error(USAGE);
|
|
115
|
+
process.exitCode = 2;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const [format, ...args] = positionals;
|
|
119
|
+
const resultFile = `${staged}.result`;
|
|
120
|
+
let value = "";
|
|
121
|
+
try {
|
|
122
|
+
try {
|
|
123
|
+
value = await readFile(staged, "utf8");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new Error("staged secret not found; it was already applied or discarded. Call secret_drop again.");
|
|
127
|
+
}
|
|
128
|
+
const message = await apply(format, args, values, staged, value);
|
|
129
|
+
await writeFile(resultFile, JSON.stringify({ ok: true, message }), { mode: 0o600 });
|
|
130
|
+
console.log(`✓ secret-drop: ${message}`);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = redact(value, error.message);
|
|
134
|
+
await writeFile(resultFile, JSON.stringify({ ok: false, message }), { mode: 0o600 }).catch(() => undefined);
|
|
135
|
+
console.error(`✗ secret-drop: ${message}`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await wipe(staged);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await main();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure placement logic: where a secret goes inside a file, and how to find it again.
|
|
3
|
+
*/
|
|
4
|
+
export const ENV_QUOTES = ["auto", "none", "single", "double"];
|
|
5
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
6
|
+
const SAFE_UNQUOTED = /^[A-Za-z0-9_\-.,/:@%+=]*$/;
|
|
7
|
+
function escapeRegExp(text) {
|
|
8
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9
|
+
}
|
|
10
|
+
function envLinePattern(key) {
|
|
11
|
+
return new RegExp(`^([ \\t]*(?:export[ \\t]+)?${escapeRegExp(key)}[ \\t]*=)(.*)$`, "gm");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Compile the placement regex with match indices so the first capture group can be replaced.
|
|
15
|
+
*/
|
|
16
|
+
function compileRegex(pattern, flags) {
|
|
17
|
+
const cleaned = [...new Set(flags.replace(/d/g, ""))].join("");
|
|
18
|
+
return new RegExp(pattern, `${cleaned}d`);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Throw when the env key or regex is malformed. Needs no file.
|
|
22
|
+
*/
|
|
23
|
+
export function validateSyntax(placement) {
|
|
24
|
+
if (placement.mode === "env" && !ENV_KEY.test(placement.key)) {
|
|
25
|
+
throw new Error(`Invalid env key "${placement.key}". Use letters, digits, "_" or ".", not starting with a digit.`);
|
|
26
|
+
}
|
|
27
|
+
if (placement.mode === "regex") {
|
|
28
|
+
try {
|
|
29
|
+
compileRegex(placement.pattern, placement.flags);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw new Error(`Invalid regex: ${error.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return a message when a file placement would replace an existing file without `overwrite`.
|
|
38
|
+
*/
|
|
39
|
+
export function checkOverwrite(placement, exists, overwrite) {
|
|
40
|
+
if (placement.mode !== "file" || !exists || overwrite)
|
|
41
|
+
return undefined;
|
|
42
|
+
return "The destination exists and file placement would replace all of it. Use env or regex to change one value; pass overwrite only for single-value files such as keys or password files.";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Throw when the placement cannot be applied to `existing` (undefined for a missing file).
|
|
46
|
+
*/
|
|
47
|
+
export function validatePlacement(placement, existing, overwrite = false) {
|
|
48
|
+
validateSyntax(placement);
|
|
49
|
+
const refused = checkOverwrite(placement, existing !== undefined, overwrite);
|
|
50
|
+
if (refused)
|
|
51
|
+
throw new Error(refused);
|
|
52
|
+
if (placement.mode !== "regex")
|
|
53
|
+
return;
|
|
54
|
+
if (existing === undefined)
|
|
55
|
+
throw new Error("regex placement needs an existing file to locate the insertion point.");
|
|
56
|
+
if (!compileRegex(placement.pattern, placement.flags).test(existing)) {
|
|
57
|
+
throw new Error(`Pattern /${placement.pattern}/${placement.flags} does not match the destination file.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function resolveQuote(quote, value) {
|
|
61
|
+
if (quote !== "auto")
|
|
62
|
+
return quote;
|
|
63
|
+
if (SAFE_UNQUOTED.test(value))
|
|
64
|
+
return "none";
|
|
65
|
+
return value.includes("'") ? "double" : "single";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Return a message when the placement cannot represent `value`, otherwise undefined.
|
|
69
|
+
*/
|
|
70
|
+
export function checkValue(placement, value) {
|
|
71
|
+
if (value.length === 0)
|
|
72
|
+
return "Value is empty.";
|
|
73
|
+
if (placement.mode !== "file" && /[\r\n]/.test(value))
|
|
74
|
+
return `${placement.mode} placement needs a single-line value.`;
|
|
75
|
+
if (placement.mode === "env") {
|
|
76
|
+
const q = resolveQuote(placement.quote, value);
|
|
77
|
+
if (q === "none" && !SAFE_UNQUOTED.test(value))
|
|
78
|
+
return "Value needs quoting; use quote auto, single, or double.";
|
|
79
|
+
if (q === "single" && value.includes("'"))
|
|
80
|
+
return "Value contains a single quote; use quote auto or double.";
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
export function encodeEnvValue(value, quote) {
|
|
85
|
+
switch (resolveQuote(quote, value)) {
|
|
86
|
+
case "none":
|
|
87
|
+
return value;
|
|
88
|
+
case "single":
|
|
89
|
+
return `'${value}'`;
|
|
90
|
+
case "double":
|
|
91
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`")}"`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Split the text after `KEY=` into the value and a trailing ` # comment` outside quotes.
|
|
96
|
+
* The comment keeps its leading whitespace; an unterminated quote yields no comment.
|
|
97
|
+
*/
|
|
98
|
+
export function splitEnvComment(raw) {
|
|
99
|
+
const start = raw.length - raw.trimStart().length;
|
|
100
|
+
const quote = raw[start];
|
|
101
|
+
if (quote === "'" || quote === '"') {
|
|
102
|
+
let end = start + 1;
|
|
103
|
+
while (end < raw.length && raw[end] !== quote)
|
|
104
|
+
end += quote === '"' && raw[end] === "\\" ? 2 : 1;
|
|
105
|
+
const rest = raw.slice(end + 1);
|
|
106
|
+
if (end < raw.length && /^\s*#/.test(rest))
|
|
107
|
+
return { value: raw.slice(0, end + 1), comment: rest };
|
|
108
|
+
return { value: raw, comment: "" };
|
|
109
|
+
}
|
|
110
|
+
const hash = raw.search(/\s#/);
|
|
111
|
+
return hash === -1 ? { value: raw, comment: "" } : { value: raw.slice(0, hash), comment: raw.slice(hash) };
|
|
112
|
+
}
|
|
113
|
+
export function decodeEnvValue(raw) {
|
|
114
|
+
const trimmed = splitEnvComment(raw).value.trim();
|
|
115
|
+
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
116
|
+
return trimmed.slice(1, -1);
|
|
117
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
118
|
+
return trimmed.slice(1, -1).replace(/\\([\\"$`])/g, "$1");
|
|
119
|
+
}
|
|
120
|
+
return trimmed;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Produce the new file content with the secret placed. `existing` is undefined for a new file.
|
|
124
|
+
*/
|
|
125
|
+
export function applyPlacement(existing, placement, value) {
|
|
126
|
+
switch (placement.mode) {
|
|
127
|
+
case "file":
|
|
128
|
+
return { content: value, summary: existing === undefined ? "created file" : "replaced file content" };
|
|
129
|
+
case "env": {
|
|
130
|
+
const encoded = encodeEnvValue(value, placement.quote);
|
|
131
|
+
const base = existing ?? "";
|
|
132
|
+
const re = envLinePattern(placement.key);
|
|
133
|
+
if (re.test(base)) {
|
|
134
|
+
re.lastIndex = 0;
|
|
135
|
+
let count = 0;
|
|
136
|
+
const content = base.replace(re, (_match, prefix, old) => {
|
|
137
|
+
count += 1;
|
|
138
|
+
const { comment } = splitEnvComment(old.replace(/\r$/, ""));
|
|
139
|
+
return `${prefix}${encoded}${comment}${old.endsWith("\r") ? "\r" : ""}`;
|
|
140
|
+
});
|
|
141
|
+
return { content, summary: `updated ${placement.key} (${count} line${count === 1 ? "" : "s"})` };
|
|
142
|
+
}
|
|
143
|
+
const separator = base.length === 0 || base.endsWith("\n") ? "" : "\n";
|
|
144
|
+
return { content: `${base}${separator}${placement.key}=${encoded}\n`, summary: `appended ${placement.key}` };
|
|
145
|
+
}
|
|
146
|
+
case "regex": {
|
|
147
|
+
if (existing === undefined)
|
|
148
|
+
throw new Error("regex placement needs an existing file.");
|
|
149
|
+
const re = compileRegex(placement.pattern, placement.flags);
|
|
150
|
+
let content = "";
|
|
151
|
+
let cursor = 0;
|
|
152
|
+
let count = 0;
|
|
153
|
+
re.lastIndex = 0;
|
|
154
|
+
let match;
|
|
155
|
+
while ((match = re.exec(existing)) !== null) {
|
|
156
|
+
const span = match.indices?.[1] ?? match.indices?.[0];
|
|
157
|
+
if (!span)
|
|
158
|
+
break;
|
|
159
|
+
content += existing.slice(cursor, span[0]) + value;
|
|
160
|
+
cursor = span[1];
|
|
161
|
+
count += 1;
|
|
162
|
+
if (!re.global)
|
|
163
|
+
break;
|
|
164
|
+
if (match[0].length === 0)
|
|
165
|
+
re.lastIndex += 1;
|
|
166
|
+
}
|
|
167
|
+
if (count === 0)
|
|
168
|
+
throw new Error(`Pattern /${placement.pattern}/${placement.flags} does not match the destination file.`);
|
|
169
|
+
content += existing.slice(cursor);
|
|
170
|
+
return { content, summary: `replaced ${count} regex match${count === 1 ? "" : "es"}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Check that `content` holds `value` at the placement. Used after a write.
|
|
176
|
+
*/
|
|
177
|
+
export function verifyPlaced(content, placement, value) {
|
|
178
|
+
if (placement.mode === "file")
|
|
179
|
+
return content === value;
|
|
180
|
+
if (placement.mode === "env")
|
|
181
|
+
return extractSecret(content, placement) === value;
|
|
182
|
+
return content.includes(value);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Recover the stored secret from file content, for redaction after a restart.
|
|
186
|
+
*/
|
|
187
|
+
export function extractSecret(content, placement) {
|
|
188
|
+
switch (placement.mode) {
|
|
189
|
+
case "file":
|
|
190
|
+
return content.length > 0 ? content : undefined;
|
|
191
|
+
case "env": {
|
|
192
|
+
let last;
|
|
193
|
+
for (const match of content.matchAll(envLinePattern(placement.key)))
|
|
194
|
+
last = decodeEnvValue(match[2] ?? "");
|
|
195
|
+
return last ? last : undefined;
|
|
196
|
+
}
|
|
197
|
+
case "regex": {
|
|
198
|
+
let re;
|
|
199
|
+
try {
|
|
200
|
+
re = compileRegex(placement.pattern, placement.flags);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const match = re.exec(content);
|
|
206
|
+
if (!match)
|
|
207
|
+
return undefined;
|
|
208
|
+
const value = match[1] ?? match[0];
|
|
209
|
+
return value.length > 0 ? value : undefined;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export function describePlacement(placement) {
|
|
214
|
+
switch (placement.mode) {
|
|
215
|
+
case "file":
|
|
216
|
+
return "whole file";
|
|
217
|
+
case "env":
|
|
218
|
+
return `env ${placement.key}`;
|
|
219
|
+
case "regex":
|
|
220
|
+
return `regex /${placement.pattern}/${placement.flags}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
package/dist/shell.js
ADDED
package/dist/write.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write for a placed secret.
|
|
3
|
+
*/
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { applyPlacement, verifyPlaced } from "./placement.js";
|
|
8
|
+
export const DEFAULT_NEW_FILE_MODE = 0o600;
|
|
9
|
+
export async function readExisting(path) {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(path, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === "ENOENT")
|
|
15
|
+
return undefined;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Replace `path` with `content` through a temp file and rename, so readers never see a partial file.
|
|
21
|
+
*/
|
|
22
|
+
async function atomicWrite(path, content, mode) {
|
|
23
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(tmp, content, { mode: 0o600, flag: "wx" });
|
|
26
|
+
await chmod(tmp, mode);
|
|
27
|
+
await rename(tmp, path);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
await unlink(tmp).catch(() => undefined);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Place `value` into `path`, write it atomically, and re-read it with `verify`.
|
|
36
|
+
* When the check fails, the original content and mode are restored from memory (a new file is
|
|
37
|
+
* removed) and the call throws; no backup file is ever written.
|
|
38
|
+
* New files get `fileMode ?? 0600`; existing files keep their mode unless `fileMode` is given.
|
|
39
|
+
*/
|
|
40
|
+
export async function writeSecret(path, placement, value, fileMode, verify = verifyPlaced) {
|
|
41
|
+
const existing = await readExisting(path);
|
|
42
|
+
const { content, summary } = applyPlacement(existing, placement, value);
|
|
43
|
+
const created = existing === undefined;
|
|
44
|
+
const originalMode = created ? undefined : (await stat(path)).mode & 0o777;
|
|
45
|
+
const mode = fileMode ?? originalMode ?? DEFAULT_NEW_FILE_MODE;
|
|
46
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
47
|
+
await atomicWrite(path, content, mode);
|
|
48
|
+
if (!verify(await readFile(path, "utf8"), placement, value)) {
|
|
49
|
+
if (existing === undefined || originalMode === undefined)
|
|
50
|
+
await unlink(path);
|
|
51
|
+
else
|
|
52
|
+
await atomicWrite(path, existing, originalMode);
|
|
53
|
+
throw new Error(`verification failed after writing; ${created ? "removed the new file" : "restored the original content"}.`);
|
|
54
|
+
}
|
|
55
|
+
return { bytes: Buffer.byteLength(content), created, mode, summary };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse an octal permission string such as "600".
|
|
59
|
+
*/
|
|
60
|
+
export function parseFileMode(raw) {
|
|
61
|
+
if (raw === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (!/^0?[0-7]{3}$/.test(raw))
|
|
64
|
+
throw new Error(`Invalid file mode "${raw}". Use octal like "600".`);
|
|
65
|
+
return Number.parseInt(raw, 8);
|
|
66
|
+
}
|