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
package/src/hand.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
/** What the agent hands over, and how each of the user's choices turns into editor or clipboard text. */
|
|
2
2
|
|
|
3
3
|
export type Kind = "shell" | "pi";
|
|
4
|
-
export type
|
|
4
|
+
export type Preferred = "prompt" | "clipboard" | "herdr-type" | "herdr-run";
|
|
5
|
+
export type Choice = Preferred | "cancel";
|
|
6
|
+
|
|
7
|
+
/** Where the hand-over can go in this session. */
|
|
8
|
+
export interface Reach {
|
|
9
|
+
hasDraft: boolean;
|
|
10
|
+
/** The local terminal UI, where the clipboard works. */
|
|
11
|
+
canCopy: boolean;
|
|
12
|
+
/** pi runs inside a Herdr pane, so a sibling pane can be opened. */
|
|
13
|
+
inHerdr: boolean;
|
|
14
|
+
}
|
|
5
15
|
|
|
6
16
|
export interface Offer {
|
|
7
17
|
command: string;
|
|
@@ -32,15 +42,30 @@ export function promptText(offer: Offer): string {
|
|
|
32
42
|
return offer.kind === "shell" ? `! ${offer.command}` : offer.command;
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
// When the preferred choice is unavailable, the next one in its chain leads instead.
|
|
46
|
+
const FALLBACK: Record<Preferred, Preferred[]> = {
|
|
47
|
+
prompt: ["prompt"],
|
|
48
|
+
clipboard: ["clipboard", "prompt"],
|
|
49
|
+
"herdr-type": ["herdr-type", "clipboard", "prompt"],
|
|
50
|
+
"herdr-run": ["herdr-run", "herdr-type", "clipboard", "prompt"],
|
|
51
|
+
};
|
|
52
|
+
|
|
35
53
|
/**
|
|
36
|
-
* The choices shown to the user
|
|
37
|
-
*
|
|
54
|
+
* The choices shown to the user, the preferred one first so the cursor starts on it. The clipboard
|
|
55
|
+
* needs the local terminal and Herdr panes need pi inside Herdr; both only make sense for a shell
|
|
56
|
+
* command, since a pi command only runs inside pi.
|
|
38
57
|
*/
|
|
39
|
-
export function options(offer: Offer,
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
list.push({ choice: "
|
|
43
|
-
|
|
58
|
+
export function options(offer: Offer, reach: Reach, preferred: Preferred = "prompt"): Option[] {
|
|
59
|
+
const shell = offer.kind === "shell";
|
|
60
|
+
const list: Option[] = [{ choice: "prompt", label: reach.hasDraft ? "Put it in my prompt (replaces my draft)" : "Put it in my prompt" }];
|
|
61
|
+
if (shell && reach.canCopy) list.push({ choice: "clipboard", label: "Copy it to the clipboard" });
|
|
62
|
+
if (shell && reach.inHerdr) {
|
|
63
|
+
list.push({ choice: "herdr-type", label: "Type it into a new Herdr pane (I press Enter)" });
|
|
64
|
+
list.push({ choice: "herdr-run", label: "Run it in a new Herdr pane" });
|
|
65
|
+
}
|
|
66
|
+
const lead = FALLBACK[preferred].find((choice) => list.some((option) => option.choice === choice)) ?? "prompt";
|
|
67
|
+
const ordered = [...list.filter((option) => option.choice === lead), ...list.filter((option) => option.choice !== lead)];
|
|
68
|
+
return [...ordered, { choice: "cancel", label: "No, thanks" }];
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
/** The selector title: why the agent hands this over, then exactly what the user would run. */
|
|
@@ -51,11 +76,15 @@ export function dialogTitle(offer: Offer, reason: string): string {
|
|
|
51
76
|
/** What the tool tells the agent after the user chose. */
|
|
52
77
|
export function resultText(choice: Choice, offer: Offer): string {
|
|
53
78
|
if (choice === "prompt") {
|
|
54
|
-
const output = offer.kind === "shell" ? "
|
|
79
|
+
const output = offer.kind === "shell" ? " When they run it, its output returns to the conversation and starts your next turn." : "";
|
|
55
80
|
return `The user put \`${promptText(offer)}\` in their prompt; it runs only when they press Enter.${output} Nothing has run yet; wait for the user.`;
|
|
56
81
|
}
|
|
57
82
|
if (choice === "clipboard") {
|
|
58
83
|
return "The user copied the command to their clipboard to run it themselves. Nothing has run yet; wait for the user to report the result.";
|
|
59
84
|
}
|
|
85
|
+
if (choice === "herdr-type" || choice === "herdr-run") {
|
|
86
|
+
const state = choice === "herdr-run" ? "It is running there" : "It runs when the user presses Enter there";
|
|
87
|
+
return `The command is in a new Herdr pane. ${state}; when it finishes, the pane closes and its output arrives as a new message. Wait for that message.`;
|
|
88
|
+
}
|
|
60
89
|
return "The user declined; nothing was placed or run. Ask how they want to proceed.";
|
|
61
90
|
}
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Herdr hand-over: open a pane below pi, run a small wrapper script there that shows the command
|
|
3
|
+
* (editable before Enter, or started at once), and record its exit code. A watcher then reads the
|
|
4
|
+
* command's output from the pane, closes the pane, and reports back. The command keeps the pane's
|
|
5
|
+
* real terminal, so password prompts and full-screen programs work.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { delimiter, join } from "node:path";
|
|
12
|
+
import { shellQuote } from "../lib/shell.ts";
|
|
13
|
+
|
|
14
|
+
export type Exec = (args: string[]) => Promise<{ stdout: string; code: number }>;
|
|
15
|
+
|
|
16
|
+
export interface HerdrRun {
|
|
17
|
+
token: string;
|
|
18
|
+
dir: string;
|
|
19
|
+
paneId: string;
|
|
20
|
+
command: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Outcome =
|
|
24
|
+
| { kind: "finished"; ran: string; exitCode: number; output: string }
|
|
25
|
+
| { kind: "cancelled" }
|
|
26
|
+
| { kind: "closed" };
|
|
27
|
+
|
|
28
|
+
export const MAX_OUTPUT_LINES = 200;
|
|
29
|
+
export const MAX_OUTPUT_CHARS = 16_000;
|
|
30
|
+
|
|
31
|
+
export const startMarker = (token: string) => `── robot-hand ${token} ──`;
|
|
32
|
+
export const endMarker = (token: string) => `── robot-hand ${token} done ──`;
|
|
33
|
+
|
|
34
|
+
/** zsh when installed (its vared edits a prefilled line), otherwise bash 4+ (read -e -i). */
|
|
35
|
+
export function findShell(path = process.env.PATH ?? ""): "zsh" | "bash" {
|
|
36
|
+
const has = (name: string) => path.split(delimiter).some((dir) => dir && existsSync(join(dir, name)));
|
|
37
|
+
return has("zsh") ? "zsh" : "bash";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The wrapper the pane runs. Ctrl+C while the line is shown cancels; during the command it stops
|
|
42
|
+
* the command as usual and the script records 130. The script writes the status file last, via a
|
|
43
|
+
* rename, so the watcher never reads it half-written.
|
|
44
|
+
*/
|
|
45
|
+
export function wrapperScript(shell: "zsh" | "bash", dir: string, token: string, type: boolean): string {
|
|
46
|
+
const q = (name: string) => shellQuote(join(dir, name));
|
|
47
|
+
const done = (value: string) => `printf '%s\\n' ${value} > ${q("status.tmp")} && mv ${q("status.tmp")} ${q("status")}`;
|
|
48
|
+
const edit = shell === "zsh" ? `vared -p '$ ' cmd` : `read -r -e -i "$cmd" -p '$ ' cmd`;
|
|
49
|
+
return [
|
|
50
|
+
`cmd=$(cat ${q("command")})`,
|
|
51
|
+
`trap '${done("cancelled").replace(/'/g, `'\\''`)}; exit 130' INT`,
|
|
52
|
+
...(type ? [edit] : []),
|
|
53
|
+
`printf '%s\\n' "$cmd" > ${q("ran")}`,
|
|
54
|
+
`trap : INT`,
|
|
55
|
+
`printf '%s\\n' ${shellQuote(startMarker(token))}`,
|
|
56
|
+
// A subshell, so an `exit` in the command ends only the command.
|
|
57
|
+
`(eval "$cmd")`,
|
|
58
|
+
`code=$?`,
|
|
59
|
+
`printf '%s\\n' ${shellQuote(endMarker(token))}`,
|
|
60
|
+
done(`"$code"`),
|
|
61
|
+
"",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The command's output between the run's markers, trimmed to what the model needs. */
|
|
66
|
+
export function sliceOutput(screen: string, token: string): string {
|
|
67
|
+
const lines = screen.split("\n");
|
|
68
|
+
const start = lines.findLastIndex((line) => line.trim() === startMarker(token));
|
|
69
|
+
const end = lines.findLastIndex((line) => line.trim() === endMarker(token));
|
|
70
|
+
let body = start === -1 ? lines : lines.slice(start + 1, end > start ? end : undefined);
|
|
71
|
+
while (body.length > 0 && body[body.length - 1]?.trim() === "") body = body.slice(0, -1);
|
|
72
|
+
let text = body.slice(-MAX_OUTPUT_LINES).join("\n");
|
|
73
|
+
if (text.length > MAX_OUTPUT_CHARS) text = text.slice(-MAX_OUTPUT_CHARS);
|
|
74
|
+
const cut = body.length > MAX_OUTPUT_LINES || body.join("\n").length > MAX_OUTPUT_CHARS;
|
|
75
|
+
return cut ? `[earlier output omitted]\n${text}` : text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Opens the pane below pi, focuses it, and starts the wrapper. */
|
|
79
|
+
export async function startRun(exec: Exec, opts: { piPane: string; cwd: string; tmp: string; command: string; type: boolean; shell: "zsh" | "bash" }): Promise<HerdrRun> {
|
|
80
|
+
const token = randomBytes(4).toString("hex");
|
|
81
|
+
const dir = join(opts.tmp, "pi-robot-hand", token);
|
|
82
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
await writeFile(join(dir, "command"), opts.command, { mode: 0o600 });
|
|
84
|
+
const script = join(dir, `run.${opts.shell}`);
|
|
85
|
+
await writeFile(script, wrapperScript(opts.shell, dir, token, opts.type), { mode: 0o600 });
|
|
86
|
+
await chmod(dir, 0o700);
|
|
87
|
+
|
|
88
|
+
const split = await exec(["pane", "split", "--pane", opts.piPane, "--direction", "down", "--ratio", "0.4", "--cwd", opts.cwd]);
|
|
89
|
+
const paneId = parsePaneId(split.stdout);
|
|
90
|
+
if (split.code !== 0 || !paneId) {
|
|
91
|
+
await rm(dir, { recursive: true, force: true });
|
|
92
|
+
throw new Error(`herdr could not open a pane: ${split.stdout.trim() || `exit ${split.code}`}`);
|
|
93
|
+
}
|
|
94
|
+
await exec(["pane", "focus", "--pane", opts.piPane, "--direction", "down"]);
|
|
95
|
+
const run = await exec(["pane", "run", paneId, `${opts.shell} ${shellQuote(script)}`]);
|
|
96
|
+
if (run.code !== 0) {
|
|
97
|
+
await exec(["pane", "close", paneId]);
|
|
98
|
+
await rm(dir, { recursive: true, force: true });
|
|
99
|
+
throw new Error(`herdr could not start the command in pane ${paneId}: exit ${run.code}`);
|
|
100
|
+
}
|
|
101
|
+
return { token, dir, paneId, command: opts.command };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parsePaneId(stdout: string): string | undefined {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(stdout) as { result?: { pane?: { pane_id?: unknown } } };
|
|
107
|
+
const id = parsed.result?.pane?.pane_id;
|
|
108
|
+
return typeof id === "string" ? id : undefined;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One watcher step: undefined while the command is still going, otherwise the outcome. A finished
|
|
116
|
+
* run has its output read and its pane closed; a pane the user closed first reports `closed`.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkRun(exec: Exec, run: HerdrRun, checkPane: boolean): Promise<Outcome | undefined> {
|
|
119
|
+
const status = await readFile(join(run.dir, "status"), "utf8").catch(() => undefined);
|
|
120
|
+
if (status === undefined) {
|
|
121
|
+
if (!checkPane) return undefined;
|
|
122
|
+
const pane = await exec(["pane", "get", run.paneId]);
|
|
123
|
+
return pane.code === 0 ? undefined : { kind: "closed" };
|
|
124
|
+
}
|
|
125
|
+
const value = status.trim();
|
|
126
|
+
if (value === "cancelled") {
|
|
127
|
+
await exec(["pane", "close", run.paneId]);
|
|
128
|
+
return { kind: "cancelled" };
|
|
129
|
+
}
|
|
130
|
+
const ran = (await readFile(join(run.dir, "ran"), "utf8").catch(() => run.command)).trim();
|
|
131
|
+
const screen = await exec(["pane", "read", run.paneId, "--source", "recent-unwrapped", "--lines", "2000"]);
|
|
132
|
+
await exec(["pane", "close", run.paneId]);
|
|
133
|
+
return { kind: "finished", ran, exitCode: Number(value), output: screen.code === 0 ? sliceOutput(screen.stdout, run.token) : "[output unavailable]" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function cleanup(run: HerdrRun): Promise<void> {
|
|
137
|
+
await rm(run.dir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The message that brings the agent back once the pane is done. */
|
|
141
|
+
export function outcomeText(outcome: Outcome, command: string): string {
|
|
142
|
+
if (outcome.kind === "cancelled") {
|
|
143
|
+
return `The user cancelled \`${command}\` in the Herdr pane before running it; nothing ran. Ask how they want to proceed.`;
|
|
144
|
+
}
|
|
145
|
+
if (outcome.kind === "closed") {
|
|
146
|
+
return `The Herdr pane for \`${command}\` closed before the command finished; whether it ran is unknown. Ask the user.`;
|
|
147
|
+
}
|
|
148
|
+
const edited = outcome.ran === command ? "" : ` (edited from \`${command}\`)`;
|
|
149
|
+
return [
|
|
150
|
+
`The user ran this in a Herdr pane${edited}, which is now closed. Exit code ${outcome.exitCode}.`,
|
|
151
|
+
"",
|
|
152
|
+
"```",
|
|
153
|
+
`$ ${outcome.ran}`,
|
|
154
|
+
outcome.output,
|
|
155
|
+
"```",
|
|
156
|
+
"",
|
|
157
|
+
"Read the output and respond to it.",
|
|
158
|
+
].join("\n");
|
|
159
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-robot-hand: the agent hands the user a command. After the user agrees, it lands in the pi
|
|
3
|
-
* prompt (shell commands as `! command`) or
|
|
4
|
-
* the agent never does.
|
|
3
|
+
* prompt (shell commands as `! command`), the clipboard, or a new Herdr pane. The user decides
|
|
4
|
+
* whether to run it; the agent never does. A Herdr pane closes when its command finishes and the
|
|
5
|
+
* output comes back as a message that starts the agent's next turn.
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
7
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
10
|
import { copyToClipboard, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
11
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
12
|
import { Type } from "typebox";
|
|
11
13
|
import { type Choice, dialogTitle, options, promptText, resultText, toOffer } from "./hand.ts";
|
|
14
|
+
import { checkRun, cleanup, type Exec, findShell, type HerdrRun, outcomeText, startRun } from "./herdr.ts";
|
|
12
15
|
|
|
13
16
|
interface HandDetails {
|
|
14
17
|
command: string;
|
|
15
18
|
status: "waiting" | Choice;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
export const HERDR_RESULT_TYPE = "robot-hand-herdr";
|
|
22
|
+
export const WATCH_MS = 500;
|
|
23
|
+
// Every this many watch ticks, also check that the pane still exists.
|
|
24
|
+
const PANE_CHECK_EVERY = 6;
|
|
25
|
+
|
|
18
26
|
const Params = Type.Object({
|
|
19
27
|
command: Type.String({
|
|
20
28
|
description: "The command to hand over. kind=shell: a shell command without a leading `!`. kind=pi: a pi slash command such as `/reload`.",
|
|
@@ -23,23 +31,74 @@ const Params = Type.Object({
|
|
|
23
31
|
kind: Type.Optional(
|
|
24
32
|
StringEnum(["shell", "pi"] as const, {
|
|
25
33
|
description:
|
|
26
|
-
"shell: goes to the prompt as `! command` (output returns to the conversation)
|
|
34
|
+
"shell: goes to the prompt as `! command` (output returns to the conversation), the clipboard, or a new Herdr pane. pi: a pi slash command, placed in the prompt as-is. Default shell.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
default: Type.Optional(
|
|
38
|
+
StringEnum(["prompt", "clipboard", "herdr-type", "herdr-run"] as const, {
|
|
39
|
+
description:
|
|
40
|
+
"The option the selector starts on. prompt (default): pi's `!`, which has no terminal for input. For interactive commands (password or sudo prompts, confirmations, ssh, full-screen programs) use herdr-type, which opens a real terminal pane; outside Herdr it falls back to clipboard, then prompt. herdr-run starts the command in the pane at once.",
|
|
27
41
|
}),
|
|
28
42
|
),
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
export function createRobotHand(copy: (text: string) => Promise<void> = copyToClipboard) {
|
|
45
|
+
export function createRobotHand(copy: (text: string) => Promise<void> = copyToClipboard, env: NodeJS.ProcessEnv = process.env, tmp: string = tmpdir()) {
|
|
32
46
|
return function robotHand(pi: ExtensionAPI) {
|
|
47
|
+
const exec: Exec = async (args) => {
|
|
48
|
+
const result = await pi.exec(env.HERDR_BIN_PATH || "herdr", args);
|
|
49
|
+
return { stdout: result.stdout, code: result.code };
|
|
50
|
+
};
|
|
51
|
+
const runs = new Map<string, { run: HerdrRun; ticks: number; busy: boolean }>();
|
|
52
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
53
|
+
|
|
54
|
+
const watch = (): void => {
|
|
55
|
+
for (const entry of runs.values()) {
|
|
56
|
+
if (entry.busy) continue;
|
|
57
|
+
entry.busy = true;
|
|
58
|
+
entry.ticks++;
|
|
59
|
+
const report = (content: string): void => {
|
|
60
|
+
pi.sendMessage({ customType: HERDR_RESULT_TYPE, content, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
61
|
+
};
|
|
62
|
+
checkRun(exec, entry.run, entry.ticks % PANE_CHECK_EVERY === 0)
|
|
63
|
+
.then(async (outcome) => {
|
|
64
|
+
if (!outcome) return;
|
|
65
|
+
runs.delete(entry.run.token);
|
|
66
|
+
await cleanup(entry.run);
|
|
67
|
+
report(outcomeText(outcome, entry.run.command));
|
|
68
|
+
})
|
|
69
|
+
.catch((error: unknown) => {
|
|
70
|
+
runs.delete(entry.run.token);
|
|
71
|
+
report(
|
|
72
|
+
`robot_hand lost track of the Herdr pane ${entry.run.paneId} running \`${entry.run.command}\`: ${(error as Error).message}. Its result is unknown; ask the user.`,
|
|
73
|
+
);
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
entry.busy = false;
|
|
77
|
+
if (runs.size === 0 && timer) {
|
|
78
|
+
clearInterval(timer);
|
|
79
|
+
timer = undefined;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
pi.on("session_shutdown", () => {
|
|
86
|
+
if (timer) clearInterval(timer);
|
|
87
|
+
timer = undefined;
|
|
88
|
+
runs.clear();
|
|
89
|
+
});
|
|
90
|
+
|
|
33
91
|
pi.registerTool({
|
|
34
92
|
name: "robot_hand",
|
|
35
93
|
label: "Robot Hand",
|
|
36
94
|
description:
|
|
37
|
-
"Hand the user a command to run themselves. The user sees the command and the reason, then puts it in their pi prompt (a shell command becomes `! command`, whose output returns to the conversation when they press Enter), copies it to the clipboard, or declines. The tool returns the user's choice; it never runs the command.",
|
|
38
|
-
promptSnippet: "Hand the user a command to run themselves, via their pi prompt or
|
|
95
|
+
"Hand the user a command to run themselves. The user sees the command and the reason, then puts it in their pi prompt (a shell command becomes `! command`, whose output returns to the conversation when they press Enter), copies it to the clipboard, sends it to a new Herdr pane (closed when it finishes, its output returning as a message), or declines. The tool returns the user's choice; it never runs the command.",
|
|
96
|
+
promptSnippet: "Hand the user a command to run themselves, via their pi prompt, clipboard, or a Herdr pane",
|
|
39
97
|
promptGuidelines: [
|
|
40
98
|
"Use robot_hand when the user has to run a command themselves: pi slash commands such as /reload, interactive or sudo commands, commands a guard blocked, or anything they asked to run on their own.",
|
|
41
99
|
"Pass one command per robot_hand call and write the reason in the user's language.",
|
|
42
|
-
"
|
|
100
|
+
"Set robot_hand `default` to herdr-type for a command that needs terminal input (password, sudo, confirmation, ssh, a full-screen program); pi's `!` gives it no terminal.",
|
|
101
|
+
"After robot_hand places, copies, or sends a command, nothing has run yet: wait for the user or the result message instead of assuming the result.",
|
|
43
102
|
],
|
|
44
103
|
parameters: Params,
|
|
45
104
|
executionMode: "sequential",
|
|
@@ -50,7 +109,13 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
|
|
|
50
109
|
}
|
|
51
110
|
const offer = toOffer(params.command, params.kind);
|
|
52
111
|
const text = promptText(offer);
|
|
53
|
-
const
|
|
112
|
+
const piPane = env.HERDR_PANE_ID;
|
|
113
|
+
const reach = {
|
|
114
|
+
hasDraft: ctx.ui.getEditorText().trim().length > 0,
|
|
115
|
+
canCopy: ctx.mode === "tui",
|
|
116
|
+
inHerdr: ctx.mode === "tui" && Boolean(piPane),
|
|
117
|
+
};
|
|
118
|
+
const choices = options(offer, reach, params.default);
|
|
54
119
|
|
|
55
120
|
onUpdate?.({ content: [{ type: "text", text: "Waiting for the user to take the command..." }], details: { command: text, status: "waiting" } });
|
|
56
121
|
pi.events.emit("herdr:blocked", { active: true, label: "Waiting for the user to take a command" });
|
|
@@ -72,6 +137,10 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
|
|
|
72
137
|
} else if (choice === "clipboard") {
|
|
73
138
|
await copy(offer.command);
|
|
74
139
|
ctx.ui.notify("Command copied to the clipboard.", "info");
|
|
140
|
+
} else if ((choice === "herdr-type" || choice === "herdr-run") && piPane) {
|
|
141
|
+
const run = await startRun(exec, { piPane, cwd: ctx.cwd, tmp, command: offer.command, type: choice === "herdr-type", shell: findShell(env.PATH) });
|
|
142
|
+
runs.set(run.token, { run, ticks: 0, busy: false });
|
|
143
|
+
timer ??= setInterval(watch, WATCH_MS);
|
|
75
144
|
}
|
|
76
145
|
return {
|
|
77
146
|
content: [{ type: "text", text: resultText(choice, offer) }],
|
|
@@ -97,6 +166,8 @@ export function createRobotHand(copy: (text: string) => Promise<void> = copyToCl
|
|
|
97
166
|
if (details.status === "waiting") return new Text(theme.fg("dim", "Waiting for you to take the command"), 0, 0);
|
|
98
167
|
if (details.status === "prompt") return new Text(`${theme.fg("success", "✓ ")}In your prompt — press Enter to run it`, 0, 0);
|
|
99
168
|
if (details.status === "clipboard") return new Text(`${theme.fg("success", "✓ ")}Copied to your clipboard`, 0, 0);
|
|
169
|
+
if (details.status === "herdr-type") return new Text(`${theme.fg("success", "✓ ")}In a new Herdr pane — press Enter there to run it`, 0, 0);
|
|
170
|
+
if (details.status === "herdr-run") return new Text(`${theme.fg("success", "✓ ")}Running in a new Herdr pane`, 0, 0);
|
|
100
171
|
return new Text(theme.fg("warning", "Declined — nothing placed or run"), 0, 0);
|
|
101
172
|
},
|
|
102
173
|
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decide whether a tool call would expose or clobber a protected secret file.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Commands that print, copy, or open file contents. Scripts that consume a file stay allowed. */
|
|
10
|
+
const READERS = new Set([
|
|
11
|
+
"cat", "bat", "less", "more", "most", "head", "tail", "tac", "nl", "pr",
|
|
12
|
+
"grep", "egrep", "fgrep", "rg", "ag", "ack", "sed", "awk", "gawk", "cut", "sort", "uniq", "paste", "column",
|
|
13
|
+
"strings", "xxd", "hexdump", "od", "base64", "base32", "diff", "cmp", "comm",
|
|
14
|
+
"vi", "vim", "nvim", "nano", "emacs", "code", "open", "pbcopy", "xclip", "xsel",
|
|
15
|
+
"cp", "mv", "scp", "rsync", "tee", "dd", "install", "jq", "yq", "dotenv", "envsubst",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Prefixes that run the next word as the command. */
|
|
19
|
+
const WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "time", "nice", "nohup", "xargs", "stdbuf", "timeout"]);
|
|
20
|
+
/** Shells whose `-c` argument is itself a command line. */
|
|
21
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
|
|
22
|
+
const MAX_NESTING = 4;
|
|
23
|
+
|
|
24
|
+
const FILE_TOOLS = new Set(["read", "edit", "write", "grep"]);
|
|
25
|
+
|
|
26
|
+
export function expandHome(path: string): string {
|
|
27
|
+
if (path === "~") return homedir();
|
|
28
|
+
if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Absolute path with symlinks resolved when the file exists. */
|
|
33
|
+
export function canonicalPath(path: string, cwd: string): string {
|
|
34
|
+
const expanded = expandHome(path.startsWith("@") ? path.slice(1) : path);
|
|
35
|
+
const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync(absolute);
|
|
38
|
+
} catch {
|
|
39
|
+
return absolute;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function blockReason(path: string): string {
|
|
44
|
+
return [
|
|
45
|
+
`${path} holds a secret managed by secret_drop; its contents stay out of the conversation.`,
|
|
46
|
+
"Let the program that needs it read the file itself, or ask the user to run the consuming command with `!`.",
|
|
47
|
+
"Call secret_drop again to change the value.",
|
|
48
|
+
].join(" ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split a command line into simple commands at `;`, `|`, `&`, newlines, and parentheses outside quotes,
|
|
53
|
+
* and at every command substitution (`$(` or a backtick) outside single quotes.
|
|
54
|
+
*/
|
|
55
|
+
function splitSegments(command: string): string[] {
|
|
56
|
+
const segments: string[] = [];
|
|
57
|
+
let current = "";
|
|
58
|
+
let quote: "'" | '"' | undefined;
|
|
59
|
+
const flush = () => {
|
|
60
|
+
if (current.trim()) segments.push(current);
|
|
61
|
+
current = "";
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < command.length; i++) {
|
|
64
|
+
const ch = command[i] ?? "";
|
|
65
|
+
if (quote === "'") {
|
|
66
|
+
if (ch === "'") quote = undefined;
|
|
67
|
+
current += ch;
|
|
68
|
+
} else if (ch === "\\") {
|
|
69
|
+
current += ch + (command[i + 1] ?? "");
|
|
70
|
+
i += 1;
|
|
71
|
+
} else if (ch === "`" || (ch === "$" && command[i + 1] === "(")) {
|
|
72
|
+
flush();
|
|
73
|
+
quote = undefined;
|
|
74
|
+
if (ch === "$") i += 1;
|
|
75
|
+
} else if (quote === '"') {
|
|
76
|
+
if (ch === '"') quote = undefined;
|
|
77
|
+
current += ch;
|
|
78
|
+
} else if (ch === "'" || ch === '"') {
|
|
79
|
+
quote = ch;
|
|
80
|
+
current += ch;
|
|
81
|
+
} else if (";|&\n()".includes(ch)) {
|
|
82
|
+
flush();
|
|
83
|
+
} else {
|
|
84
|
+
current += ch;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
flush();
|
|
88
|
+
return segments;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Shell words of one simple command, quotes removed. Redirection operators are separate words. */
|
|
92
|
+
function shellWords(segment: string): string[] {
|
|
93
|
+
const words: string[] = [];
|
|
94
|
+
let word = "";
|
|
95
|
+
let started = false;
|
|
96
|
+
let quote: "'" | '"' | undefined;
|
|
97
|
+
const push = () => {
|
|
98
|
+
if (started) words.push(word);
|
|
99
|
+
word = "";
|
|
100
|
+
started = false;
|
|
101
|
+
};
|
|
102
|
+
for (let i = 0; i < segment.length; i++) {
|
|
103
|
+
const ch = segment[i] ?? "";
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (ch === quote) quote = undefined;
|
|
106
|
+
else if (quote === '"' && ch === "\\" && i + 1 < segment.length) word += segment[++i];
|
|
107
|
+
else word += ch;
|
|
108
|
+
} else if (ch === "'" || ch === '"') {
|
|
109
|
+
quote = ch;
|
|
110
|
+
started = true;
|
|
111
|
+
} else if (ch === "\\") {
|
|
112
|
+
word += segment[++i] ?? "";
|
|
113
|
+
started = true;
|
|
114
|
+
} else if (/\s/.test(ch)) {
|
|
115
|
+
push();
|
|
116
|
+
} else if (ch === "<" || ch === ">") {
|
|
117
|
+
push();
|
|
118
|
+
let op = ch;
|
|
119
|
+
while (segment[i + 1] === "<" || segment[i + 1] === ">") op += segment[++i];
|
|
120
|
+
words.push(op);
|
|
121
|
+
} else {
|
|
122
|
+
word += ch;
|
|
123
|
+
started = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
push();
|
|
127
|
+
return words;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Return the protected path a command line reads or redirects, otherwise undefined. */
|
|
131
|
+
function findBashHit(command: string, cwd: string, isProtected: (canonical: string) => boolean, depth: number): string | undefined {
|
|
132
|
+
if (depth > MAX_NESTING) return undefined;
|
|
133
|
+
const hits = (word: string) => word.length > 0 && isProtected(canonicalPath(word, cwd));
|
|
134
|
+
for (const segment of splitSegments(command.replace(/\$\{?HOME\}?/g, homedir()))) {
|
|
135
|
+
const tokens = shellWords(segment);
|
|
136
|
+
const args: string[] = [];
|
|
137
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
138
|
+
const token = tokens[i] ?? "";
|
|
139
|
+
if (!/^[<>]+$/.test(token)) {
|
|
140
|
+
args.push(token);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const target = tokens[++i] ?? "";
|
|
144
|
+
// `<<` and `<<<` take a heredoc delimiter or a literal string, not a file.
|
|
145
|
+
if (!token.startsWith("<<") && hits(target)) return target;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let k = 0;
|
|
149
|
+
let wrapped = false;
|
|
150
|
+
while (k < args.length) {
|
|
151
|
+
const word = args[k] ?? "";
|
|
152
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) k += 1;
|
|
153
|
+
else if (WRAPPERS.has(basename(word))) {
|
|
154
|
+
wrapped = true;
|
|
155
|
+
k += 1;
|
|
156
|
+
} else if (wrapped && (word.startsWith("-") || /^\d/.test(word))) k += 1;
|
|
157
|
+
else break;
|
|
158
|
+
}
|
|
159
|
+
const name = basename(args[k] ?? "");
|
|
160
|
+
const rest = args.slice(k + 1);
|
|
161
|
+
|
|
162
|
+
if (SHELLS.has(name)) {
|
|
163
|
+
const flag = rest.findIndex((word) => /^-[a-z]*c[a-z]*$/.test(word));
|
|
164
|
+
const script = flag === -1 ? undefined : rest[flag + 1];
|
|
165
|
+
const hit = script ? findBashHit(script, cwd, isProtected, depth + 1) : undefined;
|
|
166
|
+
if (hit) return hit;
|
|
167
|
+
} else if (name === "eval") {
|
|
168
|
+
const hit = findBashHit(rest.join(" "), cwd, isProtected, depth + 1);
|
|
169
|
+
if (hit) return hit;
|
|
170
|
+
} else if (READERS.has(name)) {
|
|
171
|
+
const hit = rest.find((word) => hits(word) || (word.includes("=") && hits(word.slice(word.indexOf("=") + 1))));
|
|
172
|
+
if (hit) return hit;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Return a block reason when the call touches a protected path, otherwise undefined.
|
|
180
|
+
* `isProtected` receives canonical absolute paths.
|
|
181
|
+
*/
|
|
182
|
+
export function checkToolCall(
|
|
183
|
+
toolName: string,
|
|
184
|
+
input: Record<string, unknown>,
|
|
185
|
+
cwd: string,
|
|
186
|
+
isProtected: (canonical: string) => boolean,
|
|
187
|
+
): string | undefined {
|
|
188
|
+
|
|
189
|
+
if (FILE_TOOLS.has(toolName)) {
|
|
190
|
+
const path = input.path;
|
|
191
|
+
if (typeof path !== "string" || path.length === 0) return undefined;
|
|
192
|
+
const target = canonicalPath(path, cwd);
|
|
193
|
+
return isProtected(target) ? blockReason(path) : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (toolName === "bash") {
|
|
197
|
+
const command = input.command;
|
|
198
|
+
if (typeof command !== "string") return undefined;
|
|
199
|
+
const hit = findBashHit(command, cwd, isProtected, 0);
|
|
200
|
+
return hit ? blockReason(hit) : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|