pi-robot-hand 0.1.0 → 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/lib/apply.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { open, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { checkValue, describePlacement, ENV_QUOTES, type EnvQuote, type Placement, validatePlacement } from "./placement.ts";
|
|
11
|
+
import { shellQuote } from "./shell.ts";
|
|
12
|
+
import { parseFileMode, readExisting, writeSecret } from "./write.ts";
|
|
13
|
+
|
|
14
|
+
interface Options {
|
|
15
|
+
quote?: string;
|
|
16
|
+
flags?: string;
|
|
17
|
+
mode?: string;
|
|
18
|
+
overwrite?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const USAGE = `usage:
|
|
22
|
+
apply.js env <destination> <KEY> --from <staged> [--quote auto|none|single|double] [--mode 600]
|
|
23
|
+
apply.js regex <destination> <regex> --from <staged> [--flags g] [--mode 600]
|
|
24
|
+
apply.js file <destination> --from <staged> [--overwrite] [--mode 600]
|
|
25
|
+
apply.js exec <command using {secret}> --from <staged>`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Overwrite then delete the staged file.
|
|
29
|
+
*/
|
|
30
|
+
async function wipe(path: string): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
const { size } = await stat(path);
|
|
33
|
+
const handle = await open(path, "r+");
|
|
34
|
+
try {
|
|
35
|
+
await handle.write(Buffer.alloc(size), 0, size, 0);
|
|
36
|
+
} finally {
|
|
37
|
+
await handle.close();
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Missing or unwritable: removal below still runs.
|
|
41
|
+
}
|
|
42
|
+
await rm(path, { force: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function redact(value: string, text: string): string {
|
|
46
|
+
return value.length > 0 ? text.split(value).join("[REDACTED]") : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function chars(value: string): number {
|
|
50
|
+
return [...value].length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run a shell command with {secret} replaced by the staged file path. Stdout is discarded.
|
|
55
|
+
*/
|
|
56
|
+
async function runCommand(command: string, staged: string, value: string): Promise<string> {
|
|
57
|
+
if (!command.includes("{secret}")) throw new Error("exec command must reference {secret}.");
|
|
58
|
+
const script = command.split("{secret}").join(shellQuote(staged));
|
|
59
|
+
const child = spawn("sh", ["-c", script], { stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
let stdoutBytes = 0;
|
|
61
|
+
let stderr = "";
|
|
62
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
63
|
+
stdoutBytes += chunk.length;
|
|
64
|
+
});
|
|
65
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
66
|
+
stderr += chunk.toString("utf8");
|
|
67
|
+
});
|
|
68
|
+
const code = await new Promise<number | null>((resolve, reject) => {
|
|
69
|
+
child.on("error", reject);
|
|
70
|
+
child.on("close", resolve);
|
|
71
|
+
});
|
|
72
|
+
if (code !== 0) {
|
|
73
|
+
const tail = redact(value, stderr).trim().split("\n").slice(-15).join("\n");
|
|
74
|
+
throw new Error(`command exited ${code}${tail ? `:\n${tail}` : ""}`);
|
|
75
|
+
}
|
|
76
|
+
const suppressed = stdoutBytes > 0 ? `; ${stdoutBytes} bytes of stdout suppressed` : "";
|
|
77
|
+
return `command exited 0 — secret delivered via {secret} (${chars(value)} chars)${suppressed}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function apply(format: string | undefined, args: string[], options: Options, staged: string, value: string): Promise<string> {
|
|
81
|
+
if (format === "exec") {
|
|
82
|
+
if (args.length !== 1 || !args[0]) throw new Error(USAGE);
|
|
83
|
+
return runCommand(args[0], staged, value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let placement: Placement;
|
|
87
|
+
const destination = args[0];
|
|
88
|
+
if (format === "env" && args.length === 2 && destination && args[1]) {
|
|
89
|
+
const quote = options.quote ?? "auto";
|
|
90
|
+
if (!(ENV_QUOTES as readonly string[]).includes(quote)) throw new Error(`Invalid quote "${quote}".`);
|
|
91
|
+
placement = { mode: "env", key: args[1], quote: quote as EnvQuote };
|
|
92
|
+
} else if (format === "regex" && args.length === 2 && destination && args[1]) {
|
|
93
|
+
placement = { mode: "regex", pattern: args[1], flags: options.flags ?? "" };
|
|
94
|
+
} else if (format === "file" && args.length === 1 && destination) {
|
|
95
|
+
placement = { mode: "file" };
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(USAGE);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fileMode = parseFileMode(options.mode);
|
|
101
|
+
const problem = checkValue(placement, value);
|
|
102
|
+
if (problem) throw new Error(problem);
|
|
103
|
+
validatePlacement(placement, await readExisting(destination), options.overwrite === true);
|
|
104
|
+
const outcome = await writeSecret(destination, placement, value, fileMode);
|
|
105
|
+
const where = `${destination} (${describePlacement(placement)}, mode ${outcome.mode.toString(8)})`;
|
|
106
|
+
return `${outcome.summary} in ${where} — length check passed (${chars(value)} chars)`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
const { values, positionals } = parseArgs({
|
|
111
|
+
allowPositionals: true,
|
|
112
|
+
options: {
|
|
113
|
+
from: { type: "string" },
|
|
114
|
+
quote: { type: "string" },
|
|
115
|
+
flags: { type: "string" },
|
|
116
|
+
mode: { type: "string" },
|
|
117
|
+
overwrite: { type: "boolean" },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const staged = values.from;
|
|
121
|
+
if (!staged) {
|
|
122
|
+
console.error(USAGE);
|
|
123
|
+
process.exitCode = 2;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const [format, ...args] = positionals;
|
|
127
|
+
const resultFile = `${staged}.result`;
|
|
128
|
+
let value = "";
|
|
129
|
+
try {
|
|
130
|
+
try {
|
|
131
|
+
value = await readFile(staged, "utf8");
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error("staged secret not found; it was already applied or discarded. Call secret_drop again.");
|
|
134
|
+
}
|
|
135
|
+
const message = await apply(format, args, values, staged, value);
|
|
136
|
+
await writeFile(resultFile, JSON.stringify({ ok: true, message }), { mode: 0o600 });
|
|
137
|
+
console.log(`✓ secret-drop: ${message}`);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const message = redact(value, (error as Error).message);
|
|
140
|
+
await writeFile(resultFile, JSON.stringify({ ok: false, message }), { mode: 0o600 }).catch(() => undefined);
|
|
141
|
+
console.error(`✗ secret-drop: ${message}`);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
} finally {
|
|
144
|
+
await wipe(staged);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await main();
|
package/lib/placement.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure placement logic: where a secret goes inside a file, and how to find it again.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type EnvQuote = "auto" | "none" | "single" | "double";
|
|
6
|
+
|
|
7
|
+
export type Placement =
|
|
8
|
+
| { mode: "file" }
|
|
9
|
+
| { mode: "env"; key: string; quote: EnvQuote }
|
|
10
|
+
| { mode: "regex"; pattern: string; flags: string };
|
|
11
|
+
|
|
12
|
+
export interface PlacementResult {
|
|
13
|
+
content: string;
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ENV_QUOTES: readonly EnvQuote[] = ["auto", "none", "single", "double"];
|
|
18
|
+
|
|
19
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
20
|
+
const SAFE_UNQUOTED = /^[A-Za-z0-9_\-.,/:@%+=]*$/;
|
|
21
|
+
|
|
22
|
+
function escapeRegExp(text: string): string {
|
|
23
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function envLinePattern(key: string): RegExp {
|
|
27
|
+
return new RegExp(`^([ \\t]*(?:export[ \\t]+)?${escapeRegExp(key)}[ \\t]*=)(.*)$`, "gm");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compile the placement regex with match indices so the first capture group can be replaced.
|
|
32
|
+
*/
|
|
33
|
+
function compileRegex(pattern: string, flags: string): RegExp {
|
|
34
|
+
const cleaned = [...new Set(flags.replace(/d/g, ""))].join("");
|
|
35
|
+
return new RegExp(pattern, `${cleaned}d`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Throw when the env key or regex is malformed. Needs no file.
|
|
40
|
+
*/
|
|
41
|
+
export function validateSyntax(placement: Placement): void {
|
|
42
|
+
if (placement.mode === "env" && !ENV_KEY.test(placement.key)) {
|
|
43
|
+
throw new Error(`Invalid env key "${placement.key}". Use letters, digits, "_" or ".", not starting with a digit.`);
|
|
44
|
+
}
|
|
45
|
+
if (placement.mode === "regex") {
|
|
46
|
+
try {
|
|
47
|
+
compileRegex(placement.pattern, placement.flags);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`Invalid regex: ${(error as Error).message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return a message when a file placement would replace an existing file without `overwrite`.
|
|
56
|
+
*/
|
|
57
|
+
export function checkOverwrite(placement: Placement, exists: boolean, overwrite: boolean): string | undefined {
|
|
58
|
+
if (placement.mode !== "file" || !exists || overwrite) return undefined;
|
|
59
|
+
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.";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Throw when the placement cannot be applied to `existing` (undefined for a missing file).
|
|
64
|
+
*/
|
|
65
|
+
export function validatePlacement(placement: Placement, existing: string | undefined, overwrite = false): void {
|
|
66
|
+
validateSyntax(placement);
|
|
67
|
+
const refused = checkOverwrite(placement, existing !== undefined, overwrite);
|
|
68
|
+
if (refused) throw new Error(refused);
|
|
69
|
+
if (placement.mode !== "regex") return;
|
|
70
|
+
if (existing === undefined) throw new Error("regex placement needs an existing file to locate the insertion point.");
|
|
71
|
+
if (!compileRegex(placement.pattern, placement.flags).test(existing)) {
|
|
72
|
+
throw new Error(`Pattern /${placement.pattern}/${placement.flags} does not match the destination file.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveQuote(quote: EnvQuote, value: string): Exclude<EnvQuote, "auto"> {
|
|
77
|
+
if (quote !== "auto") return quote;
|
|
78
|
+
if (SAFE_UNQUOTED.test(value)) return "none";
|
|
79
|
+
return value.includes("'") ? "double" : "single";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Return a message when the placement cannot represent `value`, otherwise undefined.
|
|
84
|
+
*/
|
|
85
|
+
export function checkValue(placement: Placement, value: string): string | undefined {
|
|
86
|
+
if (value.length === 0) return "Value is empty.";
|
|
87
|
+
if (placement.mode !== "file" && /[\r\n]/.test(value)) return `${placement.mode} placement needs a single-line value.`;
|
|
88
|
+
if (placement.mode === "env") {
|
|
89
|
+
const q = resolveQuote(placement.quote, value);
|
|
90
|
+
if (q === "none" && !SAFE_UNQUOTED.test(value)) return "Value needs quoting; use quote auto, single, or double.";
|
|
91
|
+
if (q === "single" && value.includes("'")) return "Value contains a single quote; use quote auto or double.";
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function encodeEnvValue(value: string, quote: EnvQuote): string {
|
|
97
|
+
switch (resolveQuote(quote, value)) {
|
|
98
|
+
case "none":
|
|
99
|
+
return value;
|
|
100
|
+
case "single":
|
|
101
|
+
return `'${value}'`;
|
|
102
|
+
case "double":
|
|
103
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`")}"`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Split the text after `KEY=` into the value and a trailing ` # comment` outside quotes.
|
|
109
|
+
* The comment keeps its leading whitespace; an unterminated quote yields no comment.
|
|
110
|
+
*/
|
|
111
|
+
export function splitEnvComment(raw: string): { value: string; comment: string } {
|
|
112
|
+
const start = raw.length - raw.trimStart().length;
|
|
113
|
+
const quote = raw[start];
|
|
114
|
+
if (quote === "'" || quote === '"') {
|
|
115
|
+
let end = start + 1;
|
|
116
|
+
while (end < raw.length && raw[end] !== quote) end += quote === '"' && raw[end] === "\\" ? 2 : 1;
|
|
117
|
+
const rest = raw.slice(end + 1);
|
|
118
|
+
if (end < raw.length && /^\s*#/.test(rest)) return { value: raw.slice(0, end + 1), comment: rest };
|
|
119
|
+
return { value: raw, comment: "" };
|
|
120
|
+
}
|
|
121
|
+
const hash = raw.search(/\s#/);
|
|
122
|
+
return hash === -1 ? { value: raw, comment: "" } : { value: raw.slice(0, hash), comment: raw.slice(hash) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function decodeEnvValue(raw: string): string {
|
|
126
|
+
const trimmed = splitEnvComment(raw).value.trim();
|
|
127
|
+
if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1);
|
|
128
|
+
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
129
|
+
return trimmed.slice(1, -1).replace(/\\([\\"$`])/g, "$1");
|
|
130
|
+
}
|
|
131
|
+
return trimmed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Produce the new file content with the secret placed. `existing` is undefined for a new file.
|
|
136
|
+
*/
|
|
137
|
+
export function applyPlacement(existing: string | undefined, placement: Placement, value: string): PlacementResult {
|
|
138
|
+
switch (placement.mode) {
|
|
139
|
+
case "file":
|
|
140
|
+
return { content: value, summary: existing === undefined ? "created file" : "replaced file content" };
|
|
141
|
+
case "env": {
|
|
142
|
+
const encoded = encodeEnvValue(value, placement.quote);
|
|
143
|
+
const base = existing ?? "";
|
|
144
|
+
const re = envLinePattern(placement.key);
|
|
145
|
+
if (re.test(base)) {
|
|
146
|
+
re.lastIndex = 0;
|
|
147
|
+
let count = 0;
|
|
148
|
+
const content = base.replace(re, (_match, prefix: string, old: string) => {
|
|
149
|
+
count += 1;
|
|
150
|
+
const { comment } = splitEnvComment(old.replace(/\r$/, ""));
|
|
151
|
+
return `${prefix}${encoded}${comment}${old.endsWith("\r") ? "\r" : ""}`;
|
|
152
|
+
});
|
|
153
|
+
return { content, summary: `updated ${placement.key} (${count} line${count === 1 ? "" : "s"})` };
|
|
154
|
+
}
|
|
155
|
+
const separator = base.length === 0 || base.endsWith("\n") ? "" : "\n";
|
|
156
|
+
return { content: `${base}${separator}${placement.key}=${encoded}\n`, summary: `appended ${placement.key}` };
|
|
157
|
+
}
|
|
158
|
+
case "regex": {
|
|
159
|
+
if (existing === undefined) throw new Error("regex placement needs an existing file.");
|
|
160
|
+
const re = compileRegex(placement.pattern, placement.flags);
|
|
161
|
+
let content = "";
|
|
162
|
+
let cursor = 0;
|
|
163
|
+
let count = 0;
|
|
164
|
+
re.lastIndex = 0;
|
|
165
|
+
let match: RegExpExecArray | null;
|
|
166
|
+
while ((match = re.exec(existing)) !== null) {
|
|
167
|
+
const span = match.indices?.[1] ?? match.indices?.[0];
|
|
168
|
+
if (!span) break;
|
|
169
|
+
content += existing.slice(cursor, span[0]) + value;
|
|
170
|
+
cursor = span[1];
|
|
171
|
+
count += 1;
|
|
172
|
+
if (!re.global) break;
|
|
173
|
+
if (match[0].length === 0) re.lastIndex += 1;
|
|
174
|
+
}
|
|
175
|
+
if (count === 0) throw new Error(`Pattern /${placement.pattern}/${placement.flags} does not match the destination file.`);
|
|
176
|
+
content += existing.slice(cursor);
|
|
177
|
+
return { content, summary: `replaced ${count} regex match${count === 1 ? "" : "es"}` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check that `content` holds `value` at the placement. Used after a write.
|
|
184
|
+
*/
|
|
185
|
+
export function verifyPlaced(content: string, placement: Placement, value: string): boolean {
|
|
186
|
+
if (placement.mode === "file") return content === value;
|
|
187
|
+
if (placement.mode === "env") return extractSecret(content, placement) === value;
|
|
188
|
+
return content.includes(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Recover the stored secret from file content, for redaction after a restart.
|
|
193
|
+
*/
|
|
194
|
+
export function extractSecret(content: string, placement: Placement): string | undefined {
|
|
195
|
+
switch (placement.mode) {
|
|
196
|
+
case "file":
|
|
197
|
+
return content.length > 0 ? content : undefined;
|
|
198
|
+
case "env": {
|
|
199
|
+
let last: string | undefined;
|
|
200
|
+
for (const match of content.matchAll(envLinePattern(placement.key))) last = decodeEnvValue(match[2] ?? "");
|
|
201
|
+
return last ? last : undefined;
|
|
202
|
+
}
|
|
203
|
+
case "regex": {
|
|
204
|
+
let re: RegExp;
|
|
205
|
+
try {
|
|
206
|
+
re = compileRegex(placement.pattern, placement.flags);
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const match = re.exec(content);
|
|
211
|
+
if (!match) return undefined;
|
|
212
|
+
const value = match[1] ?? match[0];
|
|
213
|
+
return value.length > 0 ? value : undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function describePlacement(placement: Placement): string {
|
|
219
|
+
switch (placement.mode) {
|
|
220
|
+
case "file":
|
|
221
|
+
return "whole file";
|
|
222
|
+
case "env":
|
|
223
|
+
return `env ${placement.key}`;
|
|
224
|
+
case "regex":
|
|
225
|
+
return `regex /${placement.pattern}/${placement.flags}`;
|
|
226
|
+
}
|
|
227
|
+
}
|
package/lib/shell.ts
ADDED
package/lib/write.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write for a placed secret.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { applyPlacement, type Placement, verifyPlaced } from "./placement.ts";
|
|
9
|
+
|
|
10
|
+
export interface WriteOutcome {
|
|
11
|
+
bytes: number;
|
|
12
|
+
created: boolean;
|
|
13
|
+
mode: number;
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_NEW_FILE_MODE = 0o600;
|
|
18
|
+
|
|
19
|
+
export async function readExisting(path: string): Promise<string | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
return await readFile(path, "utf8");
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace `path` with `content` through a temp file and rename, so readers never see a partial file.
|
|
30
|
+
*/
|
|
31
|
+
async function atomicWrite(path: string, content: string, mode: number): Promise<void> {
|
|
32
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(tmp, content, { mode: 0o600, flag: "wx" });
|
|
35
|
+
await chmod(tmp, mode);
|
|
36
|
+
await rename(tmp, path);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await unlink(tmp).catch(() => undefined);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Place `value` into `path`, write it atomically, and re-read it with `verify`.
|
|
45
|
+
* When the check fails, the original content and mode are restored from memory (a new file is
|
|
46
|
+
* removed) and the call throws; no backup file is ever written.
|
|
47
|
+
* New files get `fileMode ?? 0600`; existing files keep their mode unless `fileMode` is given.
|
|
48
|
+
*/
|
|
49
|
+
export async function writeSecret(
|
|
50
|
+
path: string,
|
|
51
|
+
placement: Placement,
|
|
52
|
+
value: string,
|
|
53
|
+
fileMode: number | undefined,
|
|
54
|
+
verify: (content: string, placement: Placement, value: string) => boolean = verifyPlaced,
|
|
55
|
+
): Promise<WriteOutcome> {
|
|
56
|
+
const existing = await readExisting(path);
|
|
57
|
+
const { content, summary } = applyPlacement(existing, placement, value);
|
|
58
|
+
const created = existing === undefined;
|
|
59
|
+
const originalMode = created ? undefined : (await stat(path)).mode & 0o777;
|
|
60
|
+
const mode = fileMode ?? originalMode ?? DEFAULT_NEW_FILE_MODE;
|
|
61
|
+
|
|
62
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
63
|
+
await atomicWrite(path, content, mode);
|
|
64
|
+
if (!verify(await readFile(path, "utf8"), placement, value)) {
|
|
65
|
+
if (existing === undefined || originalMode === undefined) await unlink(path);
|
|
66
|
+
else await atomicWrite(path, existing, originalMode);
|
|
67
|
+
throw new Error(`verification failed after writing; ${created ? "removed the new file" : "restored the original content"}.`);
|
|
68
|
+
}
|
|
69
|
+
return { bytes: Buffer.byteLength(content), created, mode, summary };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse an octal permission string such as "600".
|
|
74
|
+
*/
|
|
75
|
+
export function parseFileMode(raw: string | undefined): number | undefined {
|
|
76
|
+
if (raw === undefined) return undefined;
|
|
77
|
+
if (!/^0?[0-7]{3}$/.test(raw)) throw new Error(`Invalid file mode "${raw}". Use octal like "600".`);
|
|
78
|
+
return Number.parseInt(raw, 8);
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-robot-hand",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The lazy kit for pi: no IDE, no copy-paste, safety net still on. The agent hands you commands (prompt, clipboard, or a Herdr pane that reports back), answers your ! commands, and takes secrets through a masked dialog it never sees",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "wayne930242",
|
|
@@ -17,21 +17,34 @@
|
|
|
17
17
|
"extension",
|
|
18
18
|
"clipboard",
|
|
19
19
|
"command",
|
|
20
|
-
"approval"
|
|
20
|
+
"approval",
|
|
21
|
+
"herdr",
|
|
22
|
+
"secret",
|
|
23
|
+
"dotenv"
|
|
21
24
|
],
|
|
22
25
|
"files": [
|
|
23
26
|
"src",
|
|
27
|
+
"lib",
|
|
28
|
+
"dist",
|
|
29
|
+
"skills",
|
|
24
30
|
"README.md",
|
|
25
31
|
"LICENSE"
|
|
26
32
|
],
|
|
27
33
|
"pi": {
|
|
28
34
|
"extensions": [
|
|
29
|
-
"./src/index.ts"
|
|
35
|
+
"./src/index.ts",
|
|
36
|
+
"./src/follow-up.ts",
|
|
37
|
+
"./src/secret-drop/index.ts"
|
|
38
|
+
],
|
|
39
|
+
"skills": [
|
|
40
|
+
"./skills"
|
|
30
41
|
]
|
|
31
42
|
},
|
|
32
43
|
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
45
|
"check": "tsc --noEmit",
|
|
34
|
-
"test": "node --test tests/*.test.ts"
|
|
46
|
+
"test": "npm run build && node --test tests/*.test.ts",
|
|
47
|
+
"prepack": "npm run build"
|
|
35
48
|
},
|
|
36
49
|
"peerDependencies": {
|
|
37
50
|
"@earendil-works/pi-ai": "*",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: secret-drop
|
|
3
|
+
description: Use when a password, token, API key, certificate, or other credential must be written to a file or fed to a command, or when working with a file that holds one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Secret Drop
|
|
7
|
+
|
|
8
|
+
Secrets reach files and commands through the `secret_drop` tool, so the value never enters the conversation. The user follows the steps you give, types it into a masked dialog or asks you a question instead; it is staged outside the project; pi pre-fills a `!` apply command in the user's prompt; the user runs it. The tool waits for that run and returns the apply script's report: what changed and a length check. Files that hold secrets are opaque to the agent: programs consume them, the agent never reads them.
|
|
9
|
+
|
|
10
|
+
## Writing a secret
|
|
11
|
+
|
|
12
|
+
1. Pick the destination the consuming program expects. For a `regex` placement in a file that is not yet protected, read its structure first.
|
|
13
|
+
2. Call `secret_drop` with a `label` naming exactly what to enter (service, account, environment), `instructions` the user can follow without asking (the exact page or menu path, every field to fill in such as name, scope, permissions, and expiration, and what to copy), the page as `url` when there is one, both in the user's language, and one format:
|
|
14
|
+
- `key` + `destination` (format `env`): sets `KEY=value`, replacing existing `KEY` / `export KEY` lines or appending one. Other lines and trailing comments stay. For a file read by `docker --env-file`, pass `quote: "none"`, since Docker keeps quotes as part of the value.
|
|
15
|
+
- `format: "regex"` + `destination` + `regex`: replaces capture group 1, or the whole match, in an existing file. Make the group cover only the value, e.g. `password: "([^"]*)"`.
|
|
16
|
+
- `format: "file"` + `destination`: the whole file is the secret, for key files and single-value files. An existing destination is refused unless `overwrite: true` is passed; pass it only to rotate a single-value file, never for a multi-value file such as `.env`.
|
|
17
|
+
- `command` (format `command`): a shell command that reads the staged file `{secret}`. Redirect results into files; stdout is discarded. Examples:
|
|
18
|
+
- `ansible-vault encrypt_string --vault-password-file .vault-pass --stdin-name db_password < {secret} >> group_vars/prod/vault.yml`
|
|
19
|
+
- `gh secret set DEPLOY_TOKEN < {secret}`
|
|
20
|
+
- `kubectl create secret generic app-tls --from-file=tls.key={secret}`
|
|
21
|
+
3. Call it once per secret and wait for the result:
|
|
22
|
+
- **Applied**: the report states the change and `length check passed (N chars)`. That report is the verification; the value is in place.
|
|
23
|
+
- **Question**: the user pressed Tab and asked instead; nothing was staged. Answer it, then call `secret_drop` again.
|
|
24
|
+
- **Cancelled or aborted**: nothing was applied. Ask the user how to proceed.
|
|
25
|
+
- **Apply failed**: the staged value was discarded. Fix the parameters from the error (a regex that does not match, a failing command) and call again.
|
|
26
|
+
|
|
27
|
+
When the user offers to paste a secret into chat, call `secret_drop` instead.
|
|
28
|
+
|
|
29
|
+
## Using a file that holds a secret
|
|
30
|
+
|
|
31
|
+
Let the program that needs the secret read the file itself: a script that loads `.env` internally, a `--env-file` or `--vault-password-file` flag, or a config path. Once `secret_drop` applies a secret, `read`, `edit`, `write`, `grep`, and bash commands that print or copy the destination are blocked, and tool output containing the value shows `[REDACTED]`. Other safety extensions block secret files such as `.env` too. When a consuming command is blocked, ask the user to run it with `!`, and continue from its output.
|
|
32
|
+
|
|
33
|
+
To change a value, call `secret_drop` again with the same destination and placement. When non-secret lines in a protected file need edits, ask the user to make them.
|
package/src/follow-up.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!` follow-up: pi records a `!` command's output but starts no turn. This extension leaves
|
|
3
|
+
* execution to pi (keeping live output and every other user_bash handler), watches the session
|
|
4
|
+
* until the command's bashExecution entry lands, then starts one turn. `!!` commands, cancelled
|
|
5
|
+
* commands, and commands typed while the agent is busy keep pi's behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export const POLL_MS = 200;
|
|
11
|
+
export const FOLLOW_UP_TYPE = "robot-hand-follow-up";
|
|
12
|
+
export const FOLLOW_UP_PROMPT = "The user just ran the shell command above with `!`. Read its output and respond to it.";
|
|
13
|
+
|
|
14
|
+
interface Recorded {
|
|
15
|
+
cancelled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function recorded(ctx: ExtensionContext, since: string | null, command: string): Recorded | undefined {
|
|
19
|
+
let id = ctx.sessionManager.getLeafId();
|
|
20
|
+
while (id && id !== since) {
|
|
21
|
+
const entry = ctx.sessionManager.getEntry(id);
|
|
22
|
+
if (!entry) return undefined;
|
|
23
|
+
if (entry.type === "message" && entry.message.role === "bashExecution" && entry.message.command === command) {
|
|
24
|
+
return entry.message;
|
|
25
|
+
}
|
|
26
|
+
id = entry.parentId;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function followUp(pi: ExtensionAPI): void {
|
|
32
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
33
|
+
|
|
34
|
+
const stop = (): void => {
|
|
35
|
+
if (timer) clearInterval(timer);
|
|
36
|
+
timer = undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
pi.on("user_bash", (event, ctx) => {
|
|
40
|
+
stop();
|
|
41
|
+
if (event.excludeFromContext || !ctx.isIdle()) return;
|
|
42
|
+
const since = ctx.sessionManager.getLeafId();
|
|
43
|
+
timer = setInterval(() => {
|
|
44
|
+
const message = recorded(ctx, since, event.command);
|
|
45
|
+
if (!message) return;
|
|
46
|
+
stop();
|
|
47
|
+
if (message.cancelled || !ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
48
|
+
pi.sendMessage({ customType: FOLLOW_UP_TYPE, content: FOLLOW_UP_PROMPT, display: false }, { triggerTurn: true });
|
|
49
|
+
}, POLL_MS);
|
|
50
|
+
});
|
|
51
|
+
pi.on("agent_start", () => stop());
|
|
52
|
+
pi.on("session_start", () => stop());
|
|
53
|
+
pi.on("session_shutdown", () => stop());
|
|
54
|
+
}
|