pi-robot-hand 0.1.1 → 0.2.1
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 +140 -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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-secret-drop: the user types a secret into a masked dialog, the extension stages it outside
|
|
3
|
+
* the project, and pre-fills a `!` command that the user runs to apply it. The model only sees
|
|
4
|
+
* where the secret went and the apply script's length check.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
|
+
import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { access } from "node:fs/promises";
|
|
11
|
+
import { join, sep } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { checkOverwrite, checkValue, describePlacement, extractSecret, type Placement, validateSyntax } from "../../lib/placement.ts";
|
|
15
|
+
import { parseFileMode, readExisting } from "../../lib/write.ts";
|
|
16
|
+
import { canonicalPath, checkToolCall } from "./guard.ts";
|
|
17
|
+
import { findLegacySecretDrop, legacyNotice } from "./legacy.ts";
|
|
18
|
+
import { Redactor } from "./redact.ts";
|
|
19
|
+
import { Registry } from "./registry.ts";
|
|
20
|
+
import { type DialogResult, dialogOptions, SecretInput } from "./secret-input.ts";
|
|
21
|
+
import {
|
|
22
|
+
type ApplyPlan,
|
|
23
|
+
buildApplyCommand,
|
|
24
|
+
clearStale,
|
|
25
|
+
discardStaged,
|
|
26
|
+
displayPath,
|
|
27
|
+
newStagedPath,
|
|
28
|
+
stageSecret,
|
|
29
|
+
waitForResult,
|
|
30
|
+
} from "./stage.ts";
|
|
31
|
+
|
|
32
|
+
const APPLY_SCRIPT = fileURLToPath(new URL("../../dist/apply.js", import.meta.url));
|
|
33
|
+
|
|
34
|
+
interface DropDetails {
|
|
35
|
+
target: string;
|
|
36
|
+
command: string;
|
|
37
|
+
status: "entering" | "staged" | "cancelled" | "question" | "applied";
|
|
38
|
+
message?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Params = Type.Object({
|
|
42
|
+
label: Type.String({ description: "What the user should enter, e.g. 'Azure SP client secret for ginlee'. In the user's language." }),
|
|
43
|
+
instructions: Type.Array(Type.String({ minLength: 1 }), {
|
|
44
|
+
minItems: 1,
|
|
45
|
+
maxItems: 10,
|
|
46
|
+
description:
|
|
47
|
+
"Numbered steps shown above the input, in the user's language, so they can get the value without asking: where to go (exact URL or menu path), what to click and fill in (name, scope, permissions, expiration), and what to copy. For a value the user already knows, one step saying which one to type.",
|
|
48
|
+
}),
|
|
49
|
+
url: Type.Optional(Type.String({ description: "The page where the user creates or finds the secret, shown as a link." })),
|
|
50
|
+
format: Type.Optional(
|
|
51
|
+
StringEnum(["env", "regex", "file", "command"] as const, {
|
|
52
|
+
description:
|
|
53
|
+
"env: set KEY=value in `destination` (replaces existing KEY lines or appends). regex: replace capture group 1 (or the whole match) of `regex` in an existing `destination`. file: the whole `destination` is the secret. command: run `command` with {secret} replaced by the staged file path. Default: env when `key` is set, command when `command` is set, otherwise file.",
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
destination: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description:
|
|
59
|
+
"env/regex/file: file the secret goes into. Relative to the working directory; ~ expands. This tool never opens it; the user's apply command writes it.",
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
key: Type.Optional(Type.String({ description: "env: variable name." })),
|
|
63
|
+
quote: Type.Optional(StringEnum(["auto", "none", "single", "double"] as const, { description: "env: value quoting. Default auto." })),
|
|
64
|
+
regex: Type.Optional(Type.String({ description: "regex: JavaScript regex locating the secret slot." })),
|
|
65
|
+
flags: Type.Optional(Type.String({ description: "regex: flags such as 'm' or 'g'." })),
|
|
66
|
+
fileMode: Type.Optional(
|
|
67
|
+
Type.String({ description: "env/regex/file: octal permissions such as '600'. Default: 600 for new files, unchanged otherwise." }),
|
|
68
|
+
),
|
|
69
|
+
overwrite: Type.Optional(
|
|
70
|
+
Type.Boolean({
|
|
71
|
+
description:
|
|
72
|
+
"file: allow replacing an existing destination. Use only for single-value files such as keys or password files; change one value in a multi-value file with env or regex.",
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
command: Type.Optional(
|
|
76
|
+
Type.String({
|
|
77
|
+
description:
|
|
78
|
+
"command: shell command that consumes the secret from the file {secret}, e.g. `ansible-vault encrypt_string --stdin-name db_password < {secret} >> vault.yml`. Its stdout is discarded; redirect results into files.",
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
interface DropParams {
|
|
84
|
+
format?: "env" | "regex" | "file" | "command";
|
|
85
|
+
destination?: string;
|
|
86
|
+
key?: string;
|
|
87
|
+
quote?: "auto" | "none" | "single" | "double";
|
|
88
|
+
regex?: string;
|
|
89
|
+
flags?: string;
|
|
90
|
+
fileMode?: string;
|
|
91
|
+
overwrite?: boolean;
|
|
92
|
+
command?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the tool returns when the user asked a question instead of entering the secret. */
|
|
96
|
+
export function questionResult(text: string): string {
|
|
97
|
+
return `The user did not enter the secret and asked instead: "${text}". Nothing was staged. Answer the question in chat, then call secret_drop again when they are ready.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function exists(path: string): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
await access(path);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function buildPlan(params: DropParams, cwd: string): Promise<ApplyPlan> {
|
|
110
|
+
const format = params.format ?? (params.key ? "env" : params.command ? "command" : "file");
|
|
111
|
+
if (format === "command") {
|
|
112
|
+
if (!params.command) throw new Error("format=command requires `command`.");
|
|
113
|
+
if (!params.command.includes("{secret}")) throw new Error("`command` must reference {secret}, the staged file path.");
|
|
114
|
+
return { format: "exec", command: params.command };
|
|
115
|
+
}
|
|
116
|
+
if (!params.destination) throw new Error(`format=${format} requires \`destination\`.`);
|
|
117
|
+
let placement: Placement;
|
|
118
|
+
if (format === "env") {
|
|
119
|
+
if (!params.key) throw new Error("format=env requires `key`.");
|
|
120
|
+
placement = { mode: "env", key: params.key, quote: params.quote ?? "auto" };
|
|
121
|
+
} else if (format === "regex") {
|
|
122
|
+
if (!params.regex) throw new Error("format=regex requires `regex`.");
|
|
123
|
+
placement = { mode: "regex", pattern: params.regex, flags: params.flags ?? "" };
|
|
124
|
+
} else {
|
|
125
|
+
placement = { mode: "file" };
|
|
126
|
+
}
|
|
127
|
+
validateSyntax(placement);
|
|
128
|
+
parseFileMode(params.fileMode);
|
|
129
|
+
const destination = canonicalPath(params.destination, cwd);
|
|
130
|
+
const overwrite = params.overwrite === true;
|
|
131
|
+
const refused = checkOverwrite(placement, await exists(destination), overwrite);
|
|
132
|
+
if (refused) throw new Error(refused);
|
|
133
|
+
return { format: "placement", destination, placement, fileMode: params.fileMode, overwrite };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default function secretDrop(pi: ExtensionAPI) {
|
|
137
|
+
const legacy = findLegacySecretDrop(getAgentDir(), process.cwd());
|
|
138
|
+
if (legacy) {
|
|
139
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
140
|
+
if (ctx.hasUI) ctx.ui.notify(legacyNotice(legacy), "warning");
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const home = join(getAgentDir(), "secret-drop");
|
|
145
|
+
const stagingDir = join(home, "staging");
|
|
146
|
+
const registry = new Registry(join(home, "registry.json"));
|
|
147
|
+
const redactor = new Redactor();
|
|
148
|
+
|
|
149
|
+
const isProtected = (path: string): boolean => {
|
|
150
|
+
const staging = canonicalPath(stagingDir, "/");
|
|
151
|
+
return path === staging || path.startsWith(`${staging}${sep}`) || registry.paths().has(path);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
async function reloadSecrets(): Promise<void> {
|
|
155
|
+
await registry.load();
|
|
156
|
+
for (const entry of registry.list()) {
|
|
157
|
+
const content = await readExisting(entry.path).catch(() => undefined);
|
|
158
|
+
if (content === undefined) continue;
|
|
159
|
+
const secret = extractSecret(content, entry.placement);
|
|
160
|
+
if (secret) redactor.add(secret);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
165
|
+
try {
|
|
166
|
+
await clearStale(stagingDir);
|
|
167
|
+
await reloadSecrets();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (ctx.hasUI) ctx.ui.notify(`secret-drop: ${(error as Error).message}`, "error");
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
174
|
+
const reason = checkToolCall(event.toolName, event.input, ctx.cwd, isProtected);
|
|
175
|
+
if (!reason) return undefined;
|
|
176
|
+
if (ctx.hasUI) ctx.ui.notify("secret-drop blocked a tool call that touches a secret file", "warning");
|
|
177
|
+
return { block: true, reason };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("tool_result", async (event) => {
|
|
181
|
+
const content = redactor.redactDeep(event.content);
|
|
182
|
+
return content === event.content ? undefined : { content };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
pi.on("context", async (event) => {
|
|
186
|
+
const messages = redactor.redactDeep(event.messages);
|
|
187
|
+
return messages === event.messages ? undefined : { messages };
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "secret_drop",
|
|
192
|
+
label: "Secret Drop",
|
|
193
|
+
description:
|
|
194
|
+
"Have the user enter a secret without it entering the conversation. The user follows the instructions you give and types it into a masked dialog, or presses Tab to ask you a question instead, which the tool returns; it is staged outside the project, and a `!` apply command is pre-filled in the user's prompt. The tool waits until the user runs that command, then returns the apply script's report: what changed and a length check, never the value. Destinations are protected afterwards: reading or printing them is blocked and the value is redacted from tool output.",
|
|
195
|
+
promptSnippet: "Have the user put a secret into a file or command without the agent seeing it",
|
|
196
|
+
promptGuidelines: [
|
|
197
|
+
"Use secret_drop whenever a password, token, key, or other credential must land in a file or be fed to a command; never ask the user to paste secrets into chat.",
|
|
198
|
+
"Files that hold secrets are consumed by programs, never read or printed by the agent.",
|
|
199
|
+
"Give secret_drop instructions the user can follow without asking: the exact page or menu, every field to fill in, and what to copy; write them and the label in the user's language.",
|
|
200
|
+
"When secret_drop returns a question from the user, answer it, then call secret_drop again.",
|
|
201
|
+
],
|
|
202
|
+
parameters: Params,
|
|
203
|
+
executionMode: "sequential",
|
|
204
|
+
|
|
205
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
206
|
+
if (ctx.mode !== "tui") {
|
|
207
|
+
throw new Error("secret_drop needs the interactive terminal UI; ask the user to write the secret themselves.");
|
|
208
|
+
}
|
|
209
|
+
const plan = await buildPlan(params, ctx.cwd);
|
|
210
|
+
const replacesFile =
|
|
211
|
+
plan.format === "placement" && plan.placement.mode === "file" && plan.overwrite === true && (await exists(plan.destination));
|
|
212
|
+
const staged = newStagedPath(stagingDir);
|
|
213
|
+
const command = `! ${buildApplyCommand(APPLY_SCRIPT, staged, plan, ctx.cwd)}`;
|
|
214
|
+
const target =
|
|
215
|
+
plan.format === "exec" ? "command" : `${displayPath(plan.destination, ctx.cwd)} (${describePlacement(plan.placement)})`;
|
|
216
|
+
const details: DropDetails = { target, command, status: "entering" };
|
|
217
|
+
const next =
|
|
218
|
+
"Enter here stages the value. Your prompt then holds a ! command; press Enter on it to apply. The agent never sees the value.";
|
|
219
|
+
const check = (candidate: string): string | undefined =>
|
|
220
|
+
plan.format === "exec" ? (candidate.length === 0 ? "Value is empty." : undefined) : checkValue(plan.placement, candidate);
|
|
221
|
+
|
|
222
|
+
onUpdate?.({ content: [{ type: "text", text: "Waiting for the user to enter the secret..." }], details });
|
|
223
|
+
pi.events.emit("herdr:blocked", { active: true, label: "Waiting for secret input" });
|
|
224
|
+
let draft: string | undefined;
|
|
225
|
+
try {
|
|
226
|
+
const choice = await ctx.ui.custom<DialogResult | null>(
|
|
227
|
+
(tui, theme, _keybindings, done) => {
|
|
228
|
+
const input = new SecretInput(
|
|
229
|
+
{
|
|
230
|
+
title: "Secret Drop",
|
|
231
|
+
label: params.label,
|
|
232
|
+
steps: params.instructions,
|
|
233
|
+
url: params.url,
|
|
234
|
+
rows: [
|
|
235
|
+
["Goes to", plan.format === "exec" ? `the command ${params.command}` : target],
|
|
236
|
+
["Next", next],
|
|
237
|
+
],
|
|
238
|
+
warning: replacesFile ? "Replaces the entire existing file" : undefined,
|
|
239
|
+
footer: "Stuck or unsure? Press Tab and ask; the dialog closes and the agent answers.",
|
|
240
|
+
check,
|
|
241
|
+
},
|
|
242
|
+
theme,
|
|
243
|
+
() => tui.requestRender(),
|
|
244
|
+
done,
|
|
245
|
+
);
|
|
246
|
+
if (signal?.aborted) input.cancel();
|
|
247
|
+
else signal?.addEventListener("abort", () => input.cancel(), { once: true });
|
|
248
|
+
return input;
|
|
249
|
+
},
|
|
250
|
+
dialogOptions(),
|
|
251
|
+
);
|
|
252
|
+
if (choice === null || choice === undefined) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: "User cancelled; nothing was staged or applied." }],
|
|
255
|
+
details: { ...details, status: "cancelled" },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (choice.kind === "question") {
|
|
259
|
+
return {
|
|
260
|
+
content: [{ type: "text", text: questionResult(choice.text) }],
|
|
261
|
+
details: { ...details, status: "question", message: choice.text },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const value = choice.value;
|
|
265
|
+
|
|
266
|
+
redactor.add(value);
|
|
267
|
+
await stageSecret(staged, value, stagingDir);
|
|
268
|
+
draft = ctx.ui.getEditorText();
|
|
269
|
+
ctx.ui.setEditorText(command);
|
|
270
|
+
ctx.ui.notify("Secret staged. Review the prefilled ! command and press Enter to apply it.", "info");
|
|
271
|
+
onUpdate?.({
|
|
272
|
+
content: [{ type: "text", text: "Secret staged; waiting for the user to run the prefilled ! command..." }],
|
|
273
|
+
details: { ...details, status: "staged" },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await waitForResult(staged, signal);
|
|
277
|
+
if (result === null) {
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text: "Aborted before the user applied the secret; the staged value was discarded." }],
|
|
280
|
+
details: { ...details, status: "cancelled" },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (!result.ok) {
|
|
284
|
+
throw new Error(`Apply failed: ${result.message}. The staged value was discarded; call secret_drop again to retry.`);
|
|
285
|
+
}
|
|
286
|
+
if (plan.format === "placement") {
|
|
287
|
+
await registry.upsert({
|
|
288
|
+
path: plan.destination,
|
|
289
|
+
placement: plan.placement,
|
|
290
|
+
label: params.label,
|
|
291
|
+
updatedAt: new Date().toISOString(),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const protection = plan.format === "placement" ? " The destination is now protected; programs consume it, the agent does not read it." : "";
|
|
295
|
+
return {
|
|
296
|
+
content: [{ type: "text", text: `Applied: ${result.message}.${protection}` }],
|
|
297
|
+
details: { ...details, status: "applied", message: result.message },
|
|
298
|
+
};
|
|
299
|
+
} finally {
|
|
300
|
+
pi.events.emit("herdr:blocked", { active: false });
|
|
301
|
+
await discardStaged(staged);
|
|
302
|
+
if (draft !== undefined && ctx.ui.getEditorText() === command) ctx.ui.setEditorText(draft);
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
renderCall(args, theme) {
|
|
307
|
+
const where = args.destination ?? args.command ?? "";
|
|
308
|
+
const head = `${theme.fg("toolTitle", theme.bold("secret_drop "))}${theme.fg("muted", where)} ${theme.fg("dim", args.label)}`;
|
|
309
|
+
// The steps stay readable here when the dialog has to shorten them.
|
|
310
|
+
const steps = (args.instructions ?? []).map((step, i) => ` ${theme.fg("accent", `${i + 1}.`)} ${step}`);
|
|
311
|
+
const url = args.url ? [` ${theme.fg("muted", "Open")} ${theme.fg("accent", args.url)}`] : [];
|
|
312
|
+
return new Text([head, ...steps, ...url].join("\n"), 0, 0);
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
renderResult(result, _options, theme) {
|
|
316
|
+
const details = result.details as DropDetails | undefined;
|
|
317
|
+
if (!details || typeof details.status !== "string") {
|
|
318
|
+
const first = result.content[0];
|
|
319
|
+
return new Text(first?.type === "text" ? first.text : "", 0, 0);
|
|
320
|
+
}
|
|
321
|
+
if (details.status === "entering") return new Text(theme.fg("dim", `Waiting for secret input → ${details.target}`), 0, 0);
|
|
322
|
+
if (details.status === "staged") return new Text(theme.fg("dim", `Staged — press Enter on: ${details.command}`), 0, 0);
|
|
323
|
+
if (details.status === "cancelled") return new Text(theme.fg("warning", "Cancelled — nothing applied"), 0, 0);
|
|
324
|
+
if (details.status === "question") return new Text(theme.fg("warning", `Asked instead — nothing applied: ${details.message ?? ""}`), 0, 0);
|
|
325
|
+
return new Text(`${theme.fg("success", "✓ ")}${theme.fg("text", details.message ?? details.target)}`, 0, 0);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
pi.registerCommand("secret-drop", {
|
|
330
|
+
description: "List protected secret files, or `forget <path>` to stop protecting one",
|
|
331
|
+
handler: async (args, ctx) => {
|
|
332
|
+
await reloadSecrets();
|
|
333
|
+
const [action, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
334
|
+
if (action === "forget") {
|
|
335
|
+
const target = rest.join(" ");
|
|
336
|
+
if (!target) {
|
|
337
|
+
ctx.ui.notify("Usage: /secret-drop forget <path>", "warning");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const removed = await registry.forget(canonicalPath(target, ctx.cwd));
|
|
341
|
+
redactor.clear();
|
|
342
|
+
await reloadSecrets();
|
|
343
|
+
ctx.ui.notify(removed > 0 ? `No longer protecting ${target}` : `${target} is not protected`, "info");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const entries = registry.list();
|
|
347
|
+
if (entries.length === 0) {
|
|
348
|
+
ctx.ui.notify("No protected secret files", "info");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const lines = entries.map((entry) => `${entry.path} — ${describePlacement(entry.placement)} (${entry.label})`);
|
|
352
|
+
ctx.ui.notify(`Protected secret files:\n${lines.join("\n")}`, "info");
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-secret-drop is now part of pi-robot-hand. When the old package is still configured, both would
|
|
3
|
+
* register `secret_drop` and pi refuses to start, so pi-robot-hand steps aside and asks the user to
|
|
4
|
+
* remove the old package.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface LegacyInstall {
|
|
11
|
+
source: string;
|
|
12
|
+
/** Settings file that lists the package. */
|
|
13
|
+
settings: string;
|
|
14
|
+
/** True for a project-local install (`pi remove -l`). */
|
|
15
|
+
local: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function packageSources(file: string): string[] {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { packages?: unknown };
|
|
21
|
+
if (!Array.isArray(parsed.packages)) return [];
|
|
22
|
+
return parsed.packages.flatMap((entry: unknown) => {
|
|
23
|
+
if (typeof entry === "string") return [entry];
|
|
24
|
+
const source = (entry as { source?: unknown } | null)?.source;
|
|
25
|
+
return typeof source === "string" ? [source] : [];
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The separately configured pi-secret-drop, project settings first, if any. */
|
|
33
|
+
export function findLegacySecretDrop(agentDir: string, cwd: string): LegacyInstall | undefined {
|
|
34
|
+
for (const [settings, local] of [
|
|
35
|
+
[join(cwd, ".pi", "settings.json"), true],
|
|
36
|
+
[join(agentDir, "settings.json"), false],
|
|
37
|
+
] as const) {
|
|
38
|
+
const source = packageSources(settings).find((entry) => /(^|[/:])pi-secret-drop(@|$|\.git|#)/.test(entry));
|
|
39
|
+
if (source) return { source, settings, local };
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function legacyNotice(legacy: LegacyInstall): string {
|
|
45
|
+
const remove = `pi remove ${legacy.local ? "-l " : ""}${legacy.source}`;
|
|
46
|
+
return `pi-robot-hand now includes secret_drop, so the separate pi-secret-drop (${legacy.settings}) is no longer needed. Remove it with \`${remove}\`, then /reload. Until then secret_drop comes from pi-secret-drop.`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace known secret values anywhere in tool output or conversation messages.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const REDACTED = "[REDACTED]";
|
|
6
|
+
/** Shorter values would redact ordinary text. */
|
|
7
|
+
export const MIN_REDACT_LENGTH = 4;
|
|
8
|
+
/** Lines of a multi-line secret this long are redacted on their own too. */
|
|
9
|
+
const MIN_LINE_LENGTH = 16;
|
|
10
|
+
|
|
11
|
+
export class Redactor {
|
|
12
|
+
private values = new Set<string>();
|
|
13
|
+
|
|
14
|
+
add(secret: string): void {
|
|
15
|
+
const normalized = secret.replace(/\r\n/g, "\n");
|
|
16
|
+
for (const candidate of [secret, normalized, normalized.trim()]) {
|
|
17
|
+
if (candidate.length >= MIN_REDACT_LENGTH) this.values.add(candidate);
|
|
18
|
+
}
|
|
19
|
+
if (normalized.includes("\n")) {
|
|
20
|
+
for (const line of normalized.split("\n")) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (trimmed.length >= MIN_LINE_LENGTH) this.values.add(trimmed);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get size(): number {
|
|
28
|
+
return this.values.size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
clear(): void {
|
|
32
|
+
this.values.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
redactText(text: string): string {
|
|
36
|
+
if (this.values.size === 0) return text;
|
|
37
|
+
let result = text;
|
|
38
|
+
// Longest first so a full secret wins over its individual lines.
|
|
39
|
+
for (const value of [...this.values].sort((a, b) => b.length - a.length)) {
|
|
40
|
+
if (result.includes(value)) result = result.split(value).join(REDACTED);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Deep-copy `input`, redacting every string. Returns the same reference when nothing changed. */
|
|
46
|
+
redactDeep<T>(input: T): T {
|
|
47
|
+
if (this.values.size === 0) return input;
|
|
48
|
+
return this.walk(input) as T;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private walk(node: unknown): unknown {
|
|
52
|
+
if (typeof node === "string") return this.redactText(node);
|
|
53
|
+
if (Array.isArray(node)) {
|
|
54
|
+
let changed = false;
|
|
55
|
+
const next = node.map((item) => {
|
|
56
|
+
const out = this.walk(item);
|
|
57
|
+
if (out !== item) changed = true;
|
|
58
|
+
return out;
|
|
59
|
+
});
|
|
60
|
+
return changed ? next : node;
|
|
61
|
+
}
|
|
62
|
+
if (node !== null && typeof node === "object") {
|
|
63
|
+
const proto = Object.getPrototypeOf(node);
|
|
64
|
+
if (proto !== Object.prototype && proto !== null) return node;
|
|
65
|
+
let changed = false;
|
|
66
|
+
const next: Record<string, unknown> = {};
|
|
67
|
+
for (const [key, value] of Object.entries(node)) {
|
|
68
|
+
const out = this.walk(value);
|
|
69
|
+
if (out !== value) changed = true;
|
|
70
|
+
next[key] = out;
|
|
71
|
+
}
|
|
72
|
+
return changed ? next : node;
|
|
73
|
+
}
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable list of files that hold secrets. It stores locations only, never values.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import type { Placement } from "../../lib/placement.ts";
|
|
8
|
+
|
|
9
|
+
export interface RegistryEntry {
|
|
10
|
+
/** Canonical absolute path. */
|
|
11
|
+
path: string;
|
|
12
|
+
placement: Placement;
|
|
13
|
+
label: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RegistryFile {
|
|
18
|
+
version: 1;
|
|
19
|
+
entries: RegistryEntry[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sameSlot(a: RegistryEntry, b: RegistryEntry): boolean {
|
|
23
|
+
if (a.path !== b.path || a.placement.mode !== b.placement.mode) return false;
|
|
24
|
+
if (a.placement.mode === "env" && b.placement.mode === "env") return a.placement.key === b.placement.key;
|
|
25
|
+
if (a.placement.mode === "regex" && b.placement.mode === "regex") {
|
|
26
|
+
return a.placement.pattern === b.placement.pattern && a.placement.flags === b.placement.flags;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Registry {
|
|
32
|
+
private entries: RegistryEntry[] = [];
|
|
33
|
+
|
|
34
|
+
readonly file: string;
|
|
35
|
+
|
|
36
|
+
constructor(file: string) {
|
|
37
|
+
this.file = file;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async load(): Promise<void> {
|
|
41
|
+
let raw: string;
|
|
42
|
+
try {
|
|
43
|
+
raw = await readFile(this.file, "utf8");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
46
|
+
this.entries = [];
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
const parsed = JSON.parse(raw) as RegistryFile;
|
|
52
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
|
53
|
+
throw new Error(`Unrecognized secret-drop registry format in ${this.file}`);
|
|
54
|
+
}
|
|
55
|
+
this.entries = parsed.entries;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list(): readonly RegistryEntry[] {
|
|
59
|
+
return this.entries;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
paths(): Set<string> {
|
|
63
|
+
return new Set(this.entries.map((entry) => entry.path));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async upsert(entry: RegistryEntry): Promise<void> {
|
|
67
|
+
this.entries = [...this.entries.filter((existing) => !sameSlot(existing, entry)), entry];
|
|
68
|
+
await this.save();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove every entry for `path`. Returns how many were removed. */
|
|
72
|
+
async forget(path: string): Promise<number> {
|
|
73
|
+
const before = this.entries.length;
|
|
74
|
+
this.entries = this.entries.filter((entry) => entry.path !== path);
|
|
75
|
+
const removed = before - this.entries.length;
|
|
76
|
+
if (removed > 0) await this.save();
|
|
77
|
+
return removed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async save(): Promise<void> {
|
|
81
|
+
await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
82
|
+
const body: RegistryFile = { version: 1, entries: this.entries };
|
|
83
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
84
|
+
await writeFile(tmp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
85
|
+
await rename(tmp, this.file);
|
|
86
|
+
await chmod(this.file, 0o600);
|
|
87
|
+
}
|
|
88
|
+
}
|