@moejay/pi-secure 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 moejay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # pi-secure
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Secrets for your tools. Not your transcript.**
4
+
5
+ Private secret entry, stdout capture, local reveal/copy, and redacted shell execution for [Pi](https://pi.dev). Transcript hygiene—not a sandbox or secret manager.
6
+
7
+ [Website](https://moejay.github.io/pi-secure/) · [Security](SECURITY.md) · [Release process](RELEASING.md)
8
+
9
+ ## Install
10
+
11
+ Requires Node.js 22.19+ and Pi 1.4+; interactive terminal mode is required for private entry and local reveal/copy. Shell execution supports macOS and Linux, not native Windows.
12
+
13
+ Install from GitHub now:
14
+
15
+ ```sh
16
+ pi install git:github.com/moejay/pi-secure
17
+ ```
18
+
19
+ After the first npm release (not published yet):
20
+
21
+ ```sh
22
+ pi install npm:@moejay/pi-secure
23
+ ```
24
+
25
+ Restart or `/reload` after installing. Reload clears in-memory secrets. For local development, use `pi -e ./index.ts` or `pi install /absolute/path/to/pi-secure`.
26
+
27
+ ## Quick start
28
+
29
+ Ask Pi: “Ask me securely for my API token, then use it to call the service.” Enter the value only in the masked dialog—not in chat, a command, or a tool argument. The agent receives a name and availability status, never the entered value.
30
+
31
+ ## Tools
32
+
33
+ ### `request_secret({ name, reason })`
34
+
35
+ The agent requests a secret; you enter it in a masked terminal dialog. Only name/availability is returned. Enter submits; Escape/Ctrl+C cancels. Empty, NUL, and multiline input cannot be submitted. Existing names are reused.
36
+
37
+ ### `capture_secret({ name, command, secrets?, trimNewline?, timeout? })`
38
+
39
+ Capture a command's stdout directly into extension memory. Neither stdout nor stderr reaches the agent, even on failure. Use this to read files or fetch API responses without exposing the value in tool results:
40
+
41
+ ```js
42
+ capture_secret({ name: "APP_TOKEN", command: "cat /private/path/token" })
43
+ // Returns { name: "APP_TOKEN", available: true } only.
44
+ show_secret({ name: "APP_TOKEN" })
45
+ ```
46
+
47
+ Commands and paths are logged normally: **never embed literal secret values in arguments**. For an authenticated API request, `secrets` injects already-stored credentials as environment variables. The command should output only the value you want to capture (e.g. use `curl -f` and `jq -er .token`). Nonzero exit, empty output, NUL, timeout, cancellation, and overflow fail without returning captured output. Existing names cannot be overwritten. Removes one trailing newline by default; set `trimNewline: false` to preserve it. This replaces the earlier `generate_secret` tool; a command can still generate random values if needed.
48
+
49
+ ### `show_secret({ name })`
50
+
51
+ Opens a local overlay **masked by default**, never a tool result:
52
+
53
+ - **R**: reveal or hide. Revealing requires **Y** confirmation.
54
+ - **C**: copy to clipboard, whether masked or visible. Copying requires **Y** confirmation.
55
+ - **N/Escape**: dismiss an action confirmation.
56
+ - **Enter/Escape/Ctrl+C**: close the dialog (Ctrl+C also closes during confirmation).
57
+
58
+ Auto-closes after 30 seconds, including while confirming or copying. Only name/shown/copied status returns to the agent. Control characters are escaped for terminal safety; large values have a truncated display but copy in full.
59
+
60
+ Clipboard support: macOS uses `/usr/bin/pbcopy`; Linux uses `wl-copy` on Wayland or `xclip`/`xsel` on X11 (install a helper separately). Values are sent through stdin, never shell arguments or terminal escape sequences. Unsupported/headless environments fail closed; there is no OSC 52, chat, or RPC fallback.
61
+
62
+ **Local display and clipboard are not recording-proof:** terminal recordings, screen capture, debug logging, clipboard managers, other apps, or clipboard sync may retain values. Hiding/closing does not erase them. The clipboard is not automatically cleared—clear clipboard/history yourself after pasting.
63
+
64
+ ### `secure_exec({ command, secrets, timeout? })`
65
+
66
+ Use named secrets for arbitrary user-authorized tasks, including saving files or credentials elsewhere:
67
+
68
+ ```js
69
+ secure_exec({
70
+ command: 'curl -sS -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/user',
71
+ secrets: ["GITHUB_TOKEN"]
72
+ })
73
+ ```
74
+
75
+ Output is buffered, then exact known secret values are redacted from stdout/stderr. No streaming or raw-output temporary files. Only child processes receive the injected environment; Pi's global environment is unchanged.
76
+
77
+ ## Storage and limits
78
+
79
+ - `/secret list` shows names; `/secret clear` forgets values.
80
+ - Values live in extension memory only, not session entries, result details, or model context. They disappear on reload, session start, shutdown, or clear. JavaScript cannot guarantee memory zeroization.
81
+ - Commands default to a 60-second timeout (configurable up to 300 seconds), with a 1 MiB combined output limit. Timeout, cancellation, and overflow discard all output.
82
+ - Concurrent private dialogs are queued. Clearing secrets or ending the session cancels active operations and invalidates queued dialogs; clear does not revoke credentials or erase external copies.
83
+
84
+ ## Security boundary
85
+
86
+ **Transcript hygiene, not a sandbox or secret manager.** Commands can use secrets arbitrarily for authorized tasks. There are no destination or file-access restrictions. Keep values out of chat: do not read secret-bearing files through ordinary tools, include values in messages, or encode them to bypass redaction. These are agent instructions, not enforced isolation. Other tools' output is not redacted.
87
+
88
+ Names, reasons, commands, availability, and redacted execution output are logged normally. Host extensions, process inspection, terminal input/debug recording, crash dumps, and the operating system are outside this protection. Only approve credentials for trusted tasks and destinations. Prefer short-lived, least-privilege tokens. Clear does not revoke credentials or erase copies held by running subprocesses.
89
+
90
+ ## Development
91
+
92
+ ```sh
93
+ npm ci --ignore-scripts
94
+ npm run check
95
+ npm pack --dry-run --ignore-scripts
96
+ ```
97
+
98
+ The package ships source directly, with no build step or runtime dependencies beyond Pi. CI checks Node 22 and 24. See [RELEASING.md](RELEASING.md) for the disabled-by-default OIDC publishing workflow and human setup checkpoint.
99
+
100
+ ## License
101
+
102
+ [MIT](LICENSE). Independent community extension; not an official Pi product.
package/RELEASING.md ADDED
@@ -0,0 +1,32 @@
1
+ # Releasing
2
+
3
+ ## Current state
4
+
5
+ The npm workflow is deliberately disabled by default. No npm credential belongs in this repo, GitHub secrets, chat, or an agent tool argument.
6
+
7
+ - Package: `@moejay/pi-secure`
8
+ - Repository: `moejay/pi-secure`
9
+ - Workflow: `publish.yml`
10
+ - GitHub environment: `npm`
11
+
12
+ ## First release (human checkpoint)
13
+
14
+ 1. Confirm ownership of the npm `moejay` scope and enable 2FA.
15
+ 2. Run `npm ci --ignore-scripts && npm run check` and inspect `npm pack --dry-run --ignore-scripts`. Smoke-test the dialogs and native clipboard on your platform.
16
+ 3. Bootstrap the package if npm requires it before trusted publisher configuration: use interactive `npm login` and `npm publish --access public` locally with 2FA. Do not give the agent a token or OTP. This first manual upload may not have provenance.
17
+ 4. In npm package settings, configure GitHub Actions trusted publishing: owner `moejay`, repo `pi-secure`, workflow filename `publish.yml`, environment `npm`. Allow direct `npm publish` (staged-only permissions will not work with this workflow).
18
+ 5. In GitHub, protect the `npm` environment with a required human reviewer; restrict its deployment branch to `main`. Protect main and release tags with rulesets. Require CI and review for workflow/code changes.
19
+ 6. Set package publishing access to **Require two-factor authentication and disallow tokens**. Revoke obsolete publishing tokens. Do not add an npm token to Actions.
20
+ 7. Set repository Actions variable `NPM_PUBLISH_ENABLED=true` only once the above is complete. Newly configured npm trust must be used within its documented expiry window (currently 2 days). If you bootstrapped 0.1.0, use a new version for the first OIDC release—npm versions cannot be overwritten.
21
+
22
+ ## Subsequent releases
23
+
24
+ 1. Update version and lockfile in a reviewed commit; merge into main after CI.
25
+ 2. Create and push the matching `vX.Y.Z` tag at the reviewed main commit.
26
+ 3. Run **Publish npm** from main, supplying the existing tag.
27
+ 4. Approve the `npm` environment deployment after reviewing the tag.
28
+ 5. Verify npm version, tarball files, and provenance before creating a GitHub release.
29
+
30
+ The workflow uses GitHub-hosted Node 24 (npm >=11.5.1 required), SHA-pinned Actions, read-only repository permissions, no dependency caching, no install lifecycle scripts, exact tag/version checks, and OIDC only. Publish permission is scoped to the protected job. It is manual, not triggered by PRs, tags, or GitHub releases.
31
+
32
+ Reference: https://docs.npmjs.com/trusted-publishers/
package/SECURITY.md ADDED
@@ -0,0 +1,18 @@
1
+ # Security policy
2
+
3
+ pi-secure protects a narrow boundary: secret values stay out of its own model-facing tool results and session entries. It is not an OS sandbox, encrypted vault, or defense against malicious agents, commands, extensions, or local observers.
4
+
5
+ ## Report a vulnerability
6
+
7
+ Please use [GitHub private vulnerability reporting](https://github.com/moejay/pi-secure/security/advisories/new) when enabled. If unavailable, open an issue asking for a private contact channel **without exploit details or credentials**. Never submit real secrets in issues, logs, screenshots, or reproductions.
8
+
9
+ Only the latest release receives fixes. Use short-lived, least-privilege credentials and revoke anything you suspect was exposed.
10
+
11
+ ## Important limits
12
+
13
+ - Shell commands can transmit or persist secrets; approve only trusted tasks.
14
+ - Exact-value output redaction does not cover transformed, encoded, partial, or already-logged values, or other tools' output.
15
+ - Terminal recording, input logging, clipboard managers/sync, process inspection, crash dumps, and other extensions are outside the boundary.
16
+ - Clipboard contents are not automatically cleared: overwriting later could destroy something else you copied. Clear your clipboard and history yourself.
17
+ - Memory is process-local; JavaScript does not guarantee zeroization.
18
+ - Names, commands, reasons, statuses, and redacted execution output are public to the agent and normal session logs.
package/clipboard.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const FAILED = "Clipboard unavailable or copy failed.";
4
+
5
+ /** Native clipboard only: never OSC 52 (which can leak into terminal logs).
6
+ * Values go only to stdin, never argv, environment, diagnostics, or results.
7
+ * Linux helpers may retain a background selection owner; we do not clear the
8
+ * clipboard automatically, since that could erase a newer clipboard value.
9
+ * spawnImpl/platform/env are injectable so tests never touch a real clipboard.
10
+ * @param {string} value
11
+ * @param {{signal?: AbortSignal, timeout?: number, spawnImpl?: typeof spawn,
12
+ * platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv}} [options]
13
+ */
14
+ export async function copyToClipboard(value, {
15
+ signal, timeout = 5000, spawnImpl = spawn,
16
+ platform = process.platform, env = process.env,
17
+ } = {}) {
18
+ if (signal?.aborted) return { copied: false, error: "Copy cancelled." };
19
+ const candidates = platform === "darwin" ? [["/usr/bin/pbcopy", []]] :
20
+ platform === "linux" ? [
21
+ ...(env.WAYLAND_DISPLAY ? [["wl-copy", ["--type", "text/plain;charset=utf-8"]]] : []),
22
+ ...(env.DISPLAY ? [["xclip", ["-selection", "clipboard", "-in"]], ["xsel", ["--clipboard", "--input"]]] : []),
23
+ ] : [];
24
+ // Preserve display/session connectivity, but do not pass unrelated credentials.
25
+ const childEnv = {};
26
+ for (const key of ["PATH", "HOME", "LANG", "LC_ALL", "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"]) {
27
+ if (typeof env[key] === "string") childEnv[key] = env[key];
28
+ }
29
+ const duration = Number.isFinite(timeout) ? Math.min(10000, Math.max(1, timeout)) : 5000;
30
+ const deadline = Date.now() + duration;
31
+ for (const [command, args] of candidates) {
32
+ if (signal?.aborted) return { copied: false, error: "Copy cancelled." };
33
+ if (Date.now() >= deadline) return { copied: false, error: "Copy timed out." };
34
+ const result = await new Promise((resolve) => {
35
+ let child, settled = false, timer;
36
+ const finish = (result, kill = false) => {
37
+ if (settled) return;
38
+ settled = true;
39
+ clearTimeout(timer);
40
+ signal?.removeEventListener("abort", abort);
41
+ if (kill) {
42
+ // Do not wait for close: a broken helper must not keep the UI alive.
43
+ try { child?.kill("SIGKILL"); } catch { /* Best effort. */ }
44
+ }
45
+ child?.stdin?.destroy();
46
+ resolve(result);
47
+ };
48
+ const abort = () => finish({ copied: false, error: "Copy cancelled." }, true);
49
+ try {
50
+ child = spawnImpl(command, args, { shell: false, stdio: ["pipe", "ignore", "ignore"], env: childEnv });
51
+ child.on("error", (error) => finish({ copied: false, missing: error?.code === "ENOENT" }, true));
52
+ // stdout/stderr are discarded at the OS boundary, including on failure.
53
+ child.on("exit", (code, exitSignal) => finish({ copied: code === 0 && !exitSignal }));
54
+ child.stdin.on("error", () => finish({ copied: false }, true));
55
+ timer = setTimeout(() => finish({ copied: false, error: "Copy timed out." }, true), Math.max(1, deadline - Date.now()));
56
+ signal?.addEventListener("abort", abort, { once: true });
57
+ if (signal?.aborted) abort();
58
+ else child.stdin.end(value, "utf8");
59
+ } catch (error) {
60
+ finish({ copied: false, missing: error?.code === "ENOENT" }, true);
61
+ }
62
+ });
63
+ if (result.copied) return { copied: true };
64
+ // Only absent executables trigger fallback. A failed write is not retried.
65
+ if (!result.missing) return { copied: false, error: result.error ?? FAILED };
66
+ }
67
+ return { copied: false, error: FAILED };
68
+ }
package/index.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
+ import { secureExec, validName } from "./secure.mjs";
5
+ import { copyToClipboard } from "./clipboard.mjs";
6
+
7
+ /** A fresh, local-only component per interaction. Confirmation never leaves the
8
+ * component, and neither rendering nor copying changes the tool's private data.
9
+ * Results describe completed actions only; masking cannot undo terminal logs.
10
+ */
11
+ export function createSecretDialog({ name, value, tui, theme, done, signal, copy = copyToClipboard }: {
12
+ name: string; value: string | undefined; tui: { requestRender(): void }; theme: { fg(color: any, text: string): string };
13
+ done(result: { shown: boolean; copied: boolean }): void; signal?: AbortSignal;
14
+ copy?: typeof copyToClipboard;
15
+ }) {
16
+ let visible = false, shown = false, copied = false, closed = false, busy = false;
17
+ let confirmation: "reveal" | "copy" | undefined;
18
+ let status = "";
19
+ const controller = new AbortController();
20
+ const cleanup = () => {
21
+ closed = true;
22
+ value = undefined;
23
+ visible = false;
24
+ clearTimeout(timer);
25
+ signal?.removeEventListener("abort", close);
26
+ controller.abort();
27
+ };
28
+ const close = () => {
29
+ if (closed) return;
30
+ cleanup();
31
+ done({ shown, copied });
32
+ };
33
+ // One absolute lifetime, not extended by keypresses, confirmations, or copies.
34
+ const timer = setTimeout(close, 30_000);
35
+ signal?.addEventListener("abort", close, { once: true });
36
+ if (signal?.aborted) close();
37
+ const performCopy = async () => {
38
+ busy = true;
39
+ status = "Copying…";
40
+ tui.requestRender();
41
+ try {
42
+ const result = await copy(value ?? "", { signal: controller.signal });
43
+ if (closed) return;
44
+ copied ||= result.copied;
45
+ status = result.copied ? "Copied. Clipboard is not automatically cleared." : "Copy failed. Native clipboard utility unavailable or failed.";
46
+ } catch {
47
+ if (!closed) status = "Copy failed."; // Never expose helper exceptions.
48
+ } finally {
49
+ busy = false;
50
+ if (!closed) tui.requestRender();
51
+ }
52
+ };
53
+ return {
54
+ render(width: number) {
55
+ if (closed) return [];
56
+ const lines = [`Secret: ${name}`];
57
+ if (visible) {
58
+ // Do not interpret control bytes, including OSC/CSI escape sequences.
59
+ const safe = (value ?? "").replace(/[\x00-\x1f\x7f-\x9f]/g, ch => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`);
60
+ shown = true;
61
+ // Keep the controls reachable even for large captured secrets.
62
+ const wrapped = wrapTextWithAnsi(safe, Math.max(1, width));
63
+ lines.push(...wrapped.slice(0, 8));
64
+ if (wrapped.length > 8) lines.push("[Display truncated; copy uses the full value.]");
65
+ } else lines.push("******** (masked)");
66
+ if (confirmation) {
67
+ lines.push(confirmation === "reveal"
68
+ ? "Reveal? Terminal recordings/scrollback may retain the value."
69
+ : "Copy? Other apps/clipboard history or sync may retain the value.");
70
+ lines.push("Y: confirm • N/Esc: back");
71
+ } else lines.push("R: reveal/hide • C: copy • Enter/Esc/Ctrl+C: close");
72
+ if (status) lines.push(status);
73
+ lines.push("Auto-closes after 30 seconds. Hiding does not erase recordings.");
74
+ return lines.map((line, i) => i === 0 ? theme.fg("accent", truncateToWidth(line, width)) : truncateToWidth(line, width));
75
+ },
76
+ handleInput(data: string) {
77
+ if (closed) return;
78
+ if (matchesKey(data, Key.ctrl("c"))) { close(); return; }
79
+ if (confirmation) {
80
+ if (data === "y" || data === "Y") {
81
+ const action = confirmation;
82
+ confirmation = undefined;
83
+ if (action === "reveal") visible = true;
84
+ else void performCopy();
85
+ } else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) confirmation = undefined;
86
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) close();
87
+ else if (data === "r" || data === "R") {
88
+ if (visible) visible = false;
89
+ else if (!busy) confirmation = "reveal";
90
+ } else if (!busy && (data === "c" || data === "C")) confirmation = "copy";
91
+ tui.requestRender();
92
+ },
93
+ invalidate() {},
94
+ dispose() { if (!closed) close(); },
95
+ };
96
+ }
97
+
98
+ export default function (pi: ExtensionAPI) {
99
+ const vault = new Map<string, string>();
100
+ // Every in-flight operation belongs to one vault lifetime. Forget/reload must
101
+ // cancel pending input, queued dialogs, and captures before clearing the vault.
102
+ let lifecycle = new AbortController();
103
+ const operationSignal = (signal?: AbortSignal) => signal
104
+ ? AbortSignal.any([signal, lifecycle.signal]) : lifecycle.signal;
105
+ const resetOperations = () => {
106
+ lifecycle.abort();
107
+ lifecycle = new AbortController();
108
+ };
109
+ let dialogQueue: Promise<unknown> = Promise.resolve();
110
+ const reply = (data: object) => ({
111
+ content: [{ type: "text" as const, text: JSON.stringify(data) }],
112
+ details: undefined,
113
+ });
114
+
115
+ pi.on("session_start", () => { resetOperations(); vault.clear(); });
116
+ pi.on("session_shutdown", () => { resetOperations(); vault.clear(); });
117
+
118
+ pi.registerCommand("secret", {
119
+ description: "List secret names or clear them: /secret list | /secret clear (never enter values here)",
120
+ handler: async (args, ctx) => {
121
+ if (args.trim() === "clear") {
122
+ resetOperations();
123
+ vault.clear();
124
+ ctx.ui.notify("Secrets forgotten.", "info");
125
+ } else if (args.trim() === "list") {
126
+ ctx.ui.notify([...vault.keys()].join(", ") || "No secrets stored.", "info");
127
+ } else {
128
+ ctx.ui.notify("Use /secret list or /secret clear. Never paste values into commands or chat.", "warning");
129
+ }
130
+ },
131
+ });
132
+
133
+ pi.registerTool(defineTool({
134
+ name: "request_secret",
135
+ label: "Request secret",
136
+ exposure: "model-only",
137
+ description: "Ask the user for a secret in a private masked dialog. Only its name and availability are returned. Use secure_exec with that name to use it. Never ask the user to paste secrets into chat. Cancelled requests must not be retried without permission.",
138
+ parameters: Type.Object({
139
+ name: Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$", description: "Environment variable name, e.g. GITHUB_TOKEN" }),
140
+ reason: Type.String({ maxLength: 500, description: "Why the credential is needed and where it will be used" }),
141
+ }),
142
+ async execute(_id, { name, reason }, signal, _update, ctx) {
143
+ signal = operationSignal(signal);
144
+ if (signal.aborted) return reply({ name, available: false });
145
+ if (!validName(name)) return reply({ available: false, error: "Invalid secret name." });
146
+ if (vault.has(name)) return reply({ name, available: true });
147
+ if (ctx.mode !== "tui") return reply({ name, available: false, error: "A private terminal dialog is required." });
148
+ // Tools may run in parallel. Never open two secret dialogs at once.
149
+ const request = dialogQueue.then(async () => {
150
+ if (signal?.aborted) return reply({ name, available: false });
151
+ if (vault.has(name)) return reply({ name, available: true });
152
+ const value = await ctx.ui.custom<string | undefined>((tui, theme, _kb, done) => {
153
+ let input: Input | undefined = new Input();
154
+ let closed = false;
155
+ const finish = (value?: string) => {
156
+ if (closed) return;
157
+ closed = true;
158
+ input = undefined;
159
+ signal?.removeEventListener("abort", cancel);
160
+ done(value);
161
+ };
162
+ const cancel = () => finish();
163
+ input.onSubmit = (value) => {
164
+ if (value && !value.includes("\0") && !/[\r\n]/.test(value)) finish(value);
165
+ };
166
+ input.onEscape = cancel;
167
+ signal?.addEventListener("abort", cancel, { once: true });
168
+ if (signal?.aborted) cancel();
169
+ return {
170
+ render(width) {
171
+ const cleanReason = reason.replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
172
+ return [
173
+ theme.fg("accent", truncateToWidth(`Secret requested: ${name}`, width)),
174
+ truncateToWidth(cleanReason, width),
175
+ truncateToWidth("Value: " + (input?.getValue() ? "********" : ""), width),
176
+ truncateToWidth("Enter: submit • Esc/Ctrl+C: cancel • single-line only", width),
177
+ truncateToWidth("Held in memory. Commands may use or save it; not a sandbox.", width),
178
+ ];
179
+ },
180
+ handleInput(data) {
181
+ if (matchesKey(data, Key.ctrl("c"))) cancel();
182
+ else input?.handleInput(data);
183
+ tui.requestRender();
184
+ },
185
+ invalidate() {},
186
+ dispose() { input = undefined; signal?.removeEventListener("abort", cancel); },
187
+ };
188
+ });
189
+ if (value === undefined || signal?.aborted) return reply({ name, available: false });
190
+ vault.set(name, value);
191
+ return reply({ name, available: true });
192
+ });
193
+ dialogQueue = request.catch(() => {});
194
+ return request;
195
+ },
196
+ }));
197
+
198
+ pi.registerTool(defineTool({
199
+ name: "capture_secret",
200
+ label: "Capture secret",
201
+ description: "Run a command and privately store its stdout as a named secret. Neither stdout nor stderr is returned, even on failure. Read a file or fetch an API response with the command; never put literal secret values in tool arguments. Existing names cannot be overwritten. Use show_secret to reveal locally or secure_exec to use the value.",
202
+ parameters: Type.Object({
203
+ name: Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }),
204
+ command: Type.String({ description: "Command whose stdout is the value to capture. Never embed a literal secret." }),
205
+ secrets: Type.Optional(Type.Array(Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }), { description: "Existing secrets to inject for authentication" })),
206
+ trimNewline: Type.Optional(Type.Boolean({ description: "Remove one trailing newline (default true)" })),
207
+ timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 300 })),
208
+ }),
209
+ async execute(_id, params, signal, _update, ctx) {
210
+ const lifetime = lifecycle;
211
+ const result = await secureExec({
212
+ isCurrent: () => lifecycle === lifetime && !lifetime.signal.aborted,
213
+ command: params.command, names: params.secrets ?? [], vault, cwd: ctx.cwd,
214
+ signal: operationSignal(signal), timeout: (params.timeout ?? 60) * 1000,
215
+ captureName: params.name, trimNewline: params.trimNewline ?? true,
216
+ });
217
+ return { ...reply(result), isError: !!result.error };
218
+ },
219
+ }));
220
+
221
+ pi.registerTool(defineTool({
222
+ name: "show_secret",
223
+ label: "Show secret locally",
224
+ exposure: "model-only",
225
+ description: "Open a masked local dialog for a named secret. R reveals/hides and C copies without requiring reveal; both actions require explicit confirmation. Returns only shown/copied status, never the value. Requires interactive terminal mode. Terminal recordings and clipboard history/sync may retain values.",
226
+ parameters: Type.Object({ name: Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }) }),
227
+ async execute(_id, { name }, signal, _update, ctx) {
228
+ if (!validName(name)) return reply({ shown: false, copied: false, error: "Invalid secret name." });
229
+ if (ctx.mode !== "tui") return reply({ name, shown: false, copied: false, error: "A local terminal dialog is required." });
230
+ signal = operationSignal(signal);
231
+ const request = dialogQueue.then(async () => {
232
+ if (signal.aborted || !vault.has(name)) return reply({ name, shown: false, copied: false });
233
+ const controller = new AbortController();
234
+ const abort = () => controller.abort();
235
+ signal?.addEventListener("abort", abort, { once: true });
236
+ if (signal?.aborted) abort();
237
+ try {
238
+ const result = await ctx.ui.custom<{ shown: boolean; copied: boolean }>((tui, theme, _kb, done) =>
239
+ createSecretDialog({ name, value: vault.get(name), tui, theme, done, signal: controller.signal }),
240
+ { overlay: true });
241
+ return reply({ name, shown: result.shown, copied: result.copied });
242
+ } finally {
243
+ controller.abort();
244
+ signal?.removeEventListener("abort", abort);
245
+ }
246
+ });
247
+ dialogQueue = request.catch(() => {});
248
+ return request;
249
+ },
250
+ }));
251
+
252
+ pi.registerTool(defineTool({
253
+ name: "secure_exec",
254
+ label: "Secure exec",
255
+ description: "Run an arbitrary shell command with named secrets injected as environment variables. Use secrets for user-authorized tasks, including saving them to files or external secret stores. Reference values as quoted environment variables, never literal values in tool arguments. Keep values out of chat: do not return them through other tools, encode them to bypass redaction, or include them in messages. Output is buffered and exact known secret values are redacted. This tool is not a sandbox and does not restrict destinations or file access.",
256
+ parameters: Type.Object({
257
+ command: Type.String(),
258
+ secrets: Type.Array(Type.String({ pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }), { minItems: 1 }),
259
+ timeout: Type.Optional(Type.Integer({ minimum: 1, maximum: 300, description: "Timeout in seconds (default 60)" })),
260
+ }),
261
+ async execute(_id, params, signal, _update, ctx) {
262
+ const lifetime = lifecycle;
263
+ const result = await secureExec({
264
+ isCurrent: () => lifecycle === lifetime && !lifetime.signal.aborted,
265
+ command: params.command, names: params.secrets, vault,
266
+ cwd: ctx.cwd, signal: operationSignal(signal), timeout: (params.timeout ?? 60) * 1000,
267
+ });
268
+ return { ...reply(result), isError: !!result.error || result.exitCode !== 0 };
269
+ },
270
+ }));
271
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@moejay/pi-secure",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Request secrets privately and use them in subprocesses without putting values in Pi transcripts.",
6
+ "keywords": ["pi-package", "pi-extension", "secrets", "security", "privacy", "credentials", "clipboard", "developer-tools"],
7
+ "license": "MIT",
8
+ "author": "moejay",
9
+ "repository": { "type": "git", "url": "git+https://github.com/moejay/pi-secure.git" },
10
+ "homepage": "https://moejay.github.io/pi-secure/",
11
+ "bugs": { "url": "https://github.com/moejay/pi-secure/issues" },
12
+ "engines": { "node": ">=22.19.0" },
13
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
14
+ "files": ["index.ts", "secure.mjs", "clipboard.mjs", "README.md", "SECURITY.md", "RELEASING.md", "LICENSE"],
15
+ "pi": { "extensions": ["./index.ts"] },
16
+ "scripts": {
17
+ "test": "node --test",
18
+ "typecheck": "tsc --noEmit",
19
+ "check": "npm run typecheck && npm test",
20
+ "prepublishOnly": "npm run check"
21
+ },
22
+ "devDependencies": {
23
+ "@earendil-works/pi-ai": "1.4.0",
24
+ "@earendil-works/pi-coding-agent": "1.4.0",
25
+ "@earendil-works/pi-tui": "1.4.0",
26
+ "@types/node": "22.20.5",
27
+ "typescript": "5.9.3"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*"
33
+ }
34
+ }
package/secure.mjs ADDED
@@ -0,0 +1,115 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export const validName = (name) => typeof name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
4
+
5
+ export function redact(text, values) {
6
+ const secrets = [...new Set(values)].filter(Boolean).sort((a, b) => b.length - a.length);
7
+ if (!secrets.length) return text;
8
+ const pattern = secrets.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
9
+ return text.replace(new RegExp(pattern, "g"), "[REDACTED]");
10
+ }
11
+
12
+ // No raw output, command, environment, or exception is returned to the caller.
13
+ // Lifecycle owners should abort in-flight work and invalidate isCurrent before
14
+ // clearing/replacing the vault. The guard also protects the capture commit.
15
+ export async function secureExec({ command, names, vault, cwd, signal, timeout = 60_000, maxBytes = 1_048_576, captureName = /** @type {string | undefined} */ (undefined), trimNewline = true, isCurrent = () => true }) {
16
+ if (typeof command !== "string" || !Array.isArray(names) ||
17
+ !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647 ||
18
+ !Number.isSafeInteger(maxBytes) || maxBytes < 0) {
19
+ return { error: "Invalid command options." };
20
+ }
21
+ if (captureName !== undefined && (!validName(captureName) || vault.has(captureName))) {
22
+ return { error: "Capture name is invalid or already exists. Choose a new name." };
23
+ }
24
+ if (signal?.aborted || !isCurrent()) return { error: "Command cancelled." };
25
+ const env = Object.assign(Object.create(null), process.env);
26
+ const values = [...vault.values()];
27
+ for (const name of names) {
28
+ if (!validName(name) || !vault.has(name)) return { error: "A requested secret is unavailable. Use request_secret first." };
29
+ env[name] = vault.get(name);
30
+ }
31
+ return new Promise((resolve) => {
32
+ let child;
33
+ try {
34
+ child = spawn("/bin/sh", ["-c", command], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
35
+ } catch {
36
+ resolve({ error: "Could not start command." });
37
+ return;
38
+ }
39
+ let stdout = [], stderr = [], size = 0, failure, finished = false;
40
+ const killGroup = () => {
41
+ if (!child.pid) return;
42
+ try { process.kill(-child.pid, "SIGKILL"); } catch { /* Already exited. */ }
43
+ };
44
+ const stop = (reason) => {
45
+ if (finished) return;
46
+ failure ??= reason;
47
+ stdout.length = 0; stderr.length = 0;
48
+ killGroup();
49
+ // An escaped descendant can retain these pipes after the shell dies.
50
+ // Never wait for it to close them after cancellation/overflow/timeout.
51
+ child.stdout.destroy();
52
+ child.stderr.destroy();
53
+ };
54
+ const abort = () => stop("Command cancelled.");
55
+ const timer = setTimeout(() => stop("Command timed out."), timeout);
56
+ signal?.addEventListener("abort", abort, { once: true });
57
+ if (signal?.aborted) abort();
58
+ const collect = (target) => (chunk) => {
59
+ if (failure || finished) return;
60
+ size += chunk.length;
61
+ if (size > maxBytes) {
62
+ stop("Command output limit exceeded; output discarded.");
63
+ return;
64
+ }
65
+ target.push(chunk);
66
+ };
67
+ child.stdout.on("data", collect(stdout));
68
+ child.stderr.on("data", collect(stderr));
69
+ child.stdout.on("error", () => stop("Could not read command output."));
70
+ child.stderr.on("error", () => stop("Could not read command output."));
71
+ const finish = (code) => {
72
+ if (finished) return;
73
+ finished = true;
74
+ clearTimeout(timer);
75
+ signal?.removeEventListener("abort", abort);
76
+ let result;
77
+ try {
78
+ if (signal?.aborted || !isCurrent()) failure ??= "Command cancelled.";
79
+ if (failure) result = { error: failure };
80
+ else if (captureName !== undefined) {
81
+ // Never return stdout OR stderr in capture mode, including on failure.
82
+ let value = Buffer.concat(stdout).toString("utf8");
83
+ if (trimNewline) value = value.replace(/\r?\n$/, "");
84
+ if (code !== 0) result = { error: "Capture command failed; output discarded." };
85
+ else if (!value || value.includes("\0")) result = { error: "Captured output is empty or contains NUL; discarded." };
86
+ else if (vault.has(captureName)) result = { error: "Secret name already exists; captured output discarded." };
87
+ else {
88
+ vault.set(captureName, value);
89
+ result = { name: captureName, available: true };
90
+ }
91
+ } else if (code === null) result = { error: "Command terminated by signal; output discarded." };
92
+ else {
93
+ // Preserve launch-time values even if forgotten/replaced, but also
94
+ // protect secrets captured by concurrent commands before this result.
95
+ const redactions = [...values, ...vault.values()];
96
+ result = {
97
+ exitCode: code,
98
+ stdout: redact(Buffer.concat(stdout).toString("utf8"), redactions),
99
+ stderr: redact(Buffer.concat(stderr).toString("utf8"), redactions),
100
+ };
101
+ }
102
+ } catch {
103
+ // Fail closed if output decoding/redaction cannot be completed.
104
+ result = { error: "Could not process command output; output discarded." };
105
+ }
106
+ stdout.length = 0; stderr.length = 0;
107
+ resolve(result);
108
+ };
109
+ // A successful shell must not leave background jobs running, even when
110
+ // they redirect their output and therefore do not delay the close event.
111
+ child.on("exit", killGroup);
112
+ child.on("error", () => { stop("Could not run command."); finish(null); });
113
+ child.on("close", finish);
114
+ });
115
+ }