@monster-orange/den 0.4.25 → 0.4.26

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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.26] - 2026-11-10
8
+
9
+ ### Security
10
+
11
+ - **A secret saved to 1Password is never on a command line.** `op`
12
+ wrote the value as an argument (`field[password]=<value>`), which any
13
+ process on the machine can read while `op` runs. Saving the admin
14
+ password (and every other 1Password write) now reads the item as JSON,
15
+ changes the one field and passes it on `op`'s stdin (`op item edit`, or
16
+ `op item create -` for a new item). The item's other fields are kept.
17
+ Nothing to change in config. A sweep of Den's other commands found no
18
+ other secret in argv: sudo and Doppler already read theirs on stdin,
19
+ and agent keys go in the environment.
20
+
7
21
  ## [0.4.25] - 2026-11-10
8
22
 
9
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
  * 1Password strategy — reads/writes item fields with the `op` CLI.
3
3
  * Selected with `secrets.provider: 1password`.
4
4
  */
5
- import { execFile } from "child_process";
5
+ import { execFile, spawn } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import type { PreflightResult, SecretRef, SecretsProvider } from "./types.js";
8
8
  import {
@@ -128,30 +128,108 @@ export class OnePasswordProvider implements SecretsProvider {
128
128
  }
129
129
  }
130
130
 
131
- /** Overwrite the field; an item that isn't there yet is created (a Password item). */
131
+ /**
132
+ * Run `op` with `input` on its stdin. A secret goes this way, never in
133
+ * argv: any process on the machine can read another's command line.
134
+ */
135
+ private async runWithInput(args: string[], input: string): Promise<string> {
136
+ const { token: _t, ...opts } = await this.opOptions();
137
+ return new Promise((resolve, reject) => {
138
+ const child = spawn("op", args, {
139
+ env: opts.env,
140
+ stdio: ["pipe", "pipe", "pipe"],
141
+ });
142
+ let stdout = "";
143
+ let stderr = "";
144
+ let killed = false;
145
+ const timer = setTimeout(() => {
146
+ killed = true;
147
+ child.kill("SIGTERM");
148
+ }, opts.timeout);
149
+ child.stdout.on("data", (d) => (stdout += String(d)));
150
+ child.stderr.on("data", (d) => (stderr += String(d)));
151
+ child.on("error", (e) => {
152
+ clearTimeout(timer);
153
+ reject(e);
154
+ });
155
+ child.on("close", (code) => {
156
+ clearTimeout(timer);
157
+ if (code === 0) resolve(stdout);
158
+ else
159
+ reject(
160
+ Object.assign(new Error(stderr.trim() || `op exited ${code}`), {
161
+ stderr,
162
+ killed,
163
+ code,
164
+ }),
165
+ );
166
+ });
167
+ child.stdin.on("error", () => undefined);
168
+ child.stdin.end(input);
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Overwrite the field; an item that isn't there yet is created (a
174
+ * Password item). The value only ever travels on op's stdin, as the
175
+ * item's JSON (op item edit / op item create -), never in argv.
176
+ */
132
177
  async set(ref: SecretRef, value: string): Promise<void> {
133
178
  const vault = ref.vault ? ["--vault", ref.vault] : [];
134
- const field = `${ref.field}[password]=${value}`;
179
+ type Field = {
180
+ id?: string;
181
+ label?: string;
182
+ type?: string;
183
+ purpose?: string;
184
+ value?: string;
185
+ };
186
+ let item: { id?: string; fields?: Field[] } | null = null;
187
+ let readErr: unknown = null;
135
188
  try {
136
- await this.run(["item", "edit", ref.item, ...vault, field], true);
137
- } catch (err) {
138
- try {
189
+ item = JSON.parse(
139
190
  await this.run(
140
- [
141
- "item",
142
- "create",
143
- "--category",
144
- "password",
145
- "--title",
146
- ref.item,
147
- ...vault,
148
- field,
149
- ],
191
+ ["item", "get", ref.item, ...vault, "--format", "json", "--reveal"],
150
192
  true,
151
- );
152
- } catch {
153
- throw err;
154
- }
193
+ ),
194
+ ) as { id?: string; fields?: Field[] };
195
+ } catch (err) {
196
+ readErr = err;
197
+ }
198
+ if (item) {
199
+ const fields = item.fields ?? [];
200
+ const f = fields.find((x) => x.label === ref.field || x.id === ref.field);
201
+ if (f) {
202
+ f.value = value;
203
+ f.type = "CONCEALED";
204
+ } else fields.push({ label: ref.field, type: "CONCEALED", value });
205
+ item.fields = fields;
206
+ await this.runWithInput(
207
+ ["item", "edit", item.id || ref.item, ...vault],
208
+ JSON.stringify(item),
209
+ );
210
+ return;
211
+ }
212
+ const field: Field =
213
+ ref.field === "password"
214
+ ? {
215
+ id: "password",
216
+ type: "CONCEALED",
217
+ purpose: "PASSWORD",
218
+ label: "password",
219
+ value,
220
+ }
221
+ : { label: ref.field, type: "CONCEALED", value };
222
+ try {
223
+ await this.runWithInput(
224
+ ["item", "create", ...vault, "-"],
225
+ JSON.stringify({
226
+ title: ref.item,
227
+ category: "PASSWORD",
228
+ fields: [field],
229
+ }),
230
+ );
231
+ } catch {
232
+ throw readErr;
155
233
  }
156
234
  }
157
235