@monotykamary/pi-localterm 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 Aiden Bai
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 ADDED
@@ -0,0 +1,71 @@
1
+ # @monotykamary/pi-localterm
2
+
3
+ A [pi](https://github.com/earendil-works/pi-coding-agent) extension that integrates [localterm](https://github.com/monotykamary/localterm) with pi. Two features, both inert outside localterm:
4
+
5
+ 1. **Kitty graphics + OSC 8 links** — localterm renders xterm.js with the Kitty graphics and web-links addons loaded, but sets `TERM=xterm-256color` and strips terminal-identity env vars (so Ink TUIs don't probe for a protocol xterm.js lacks). pi-tui therefore reports images/hyperlinks as unsupported. This extension detects `LOCALTERM=1` (injected into every localterm PTY) and force-enables those capabilities, so images and links render in the browser.
6
+ 2. **Secret scrubbing for the agent's bash tool** — localterm injects a secret only into the shimmed process's env (pi's), not its parent shell. But pi's bash tool spawns commands with `{ ...process.env }`, so without this the agent's commands would inherit every secret pi received. This extension overrides the `bash` tool with a spawn hook that deletes the `pi` process's localterm-managed secret env vars from each command's child env only — pi's own `process.env` (and its provider calls) keep them.
7
+
8
+ ## Install
9
+
10
+ From npm (published package):
11
+
12
+ ```bash
13
+ pi install npm:@monotykamary/pi-localterm
14
+ ```
15
+
16
+ From source (this monorepo, for a one-off run):
17
+
18
+ ```bash
19
+ git clone https://github.com/monotykamary/localterm
20
+ pi -e ./localterm/packages/pi-extension
21
+ ```
22
+
23
+ <details>
24
+ <summary>Manual install (load on every session)</summary>
25
+
26
+ Clone and add the package to pi's global settings so it loads automatically:
27
+
28
+ ```bash
29
+ git clone https://github.com/monotykamary/localterm ~/src/localterm
30
+ ```
31
+
32
+ Then edit `~/.pi/agent/settings.json`:
33
+
34
+ ```json
35
+ {
36
+ "packages": ["/Users/you/src/localterm/packages/pi-extension"]
37
+ }
38
+ ```
39
+
40
+ Or, for a project-only install, add the same path to `<cwd>/.pi/settings.json` instead. Then `/reload` in pi.
41
+
42
+ </details>
43
+
44
+ The extension auto-activates only inside localterm (`LOCALTERM=1`); outside localterm it registers nothing and pi behaves exactly as default.
45
+
46
+ ## How the scrub works
47
+
48
+ localterm stores secret **policy** (names + the env var each exports) in `~/.localterm/secrets.json` and per-process wiring (`pi` → which secret names it receives) in `~/.localterm/processes.json`. **Only names and env vars — never values** (values live in the macOS Keychain). The extension reads those two files to find the env-var names the `pi` process is wired to, and strips exactly those from each bash-tool child's environment.
49
+
50
+ Resolution mirrors the shim 1:1: the shim injects the `pi` process's `requestedSecrets` (resolved to env vars); the scrub strips exactly that set. The strip set is recomputed on `session_start` (new / resume / fork / reload), so a policy change is picked up on the next session transition.
51
+
52
+ This extends localterm's existing least-privilege property — "the shimmed binary sees the key, its parent shell doesn't" — to "pi sees the key, the agent's bash commands don't." It converts silent env inheritance into an explicit, greppable `localterm secret get <name>` call when an agent's command genuinely needs a key.
53
+
54
+ ## What this is not
55
+
56
+ This is **defense-in-depth, not a security boundary**. The keys still live in pi's own `process.env`, so a command the agent generates can recover them via parent-process introspection (`ps eww $PPID` on macOS, `/proc/$PPID/environ` on Linux) or by shelling out to `security find-generic-password` directly (the shim's own resolution path). The scrub stops passive/accidental leakage (`env`, `printenv`, a script reading `$VAR`), not active exfiltration by a prompt-injected or adversarial agent.
57
+
58
+ For untrusted or unmonitored agents, **don't wire secrets to the `pi` process at all** — give pi its provider keys through pi's own config, and run pi in a container/VM/micro-VM with short-lived credentials.
59
+
60
+ ## Overriding the bash tool
61
+
62
+ The scrub overrides pi's built-in `bash` tool **by name** (extensions apply after the built-in in pi's tool registry). It reconstructs the tool via `createBashToolDefinition` and preserves a user's configured `shellPath` and `shellCommandPrefix` (read from `~/.pi/agent/settings.json` + `<cwd>/.pi/settings.json`) so the override is behavior-identical to the built-in apart from the env scrub. If another extension also overrides `bash`, only the first-registered one wins — that's a pi-level constraint.
63
+
64
+ ## Requirements
65
+
66
+ - pi ≥ 0.80 (uses the `spawnHook` tool option and `createBashToolDefinition` export).
67
+ - localterm with `LOCALTERM=1` in the PTY environment (v0.7+). The scrub additionally needs localterm's secrets/processes policy files on the same machine.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,38 @@
1
+ import type { BashSpawnHook, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createBashToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { readLocaltermSecretEnvVarsForPi } from "../src/utils/read-localterm-secret-policy.js";
4
+ import { readPiShellSettings } from "../src/utils/read-pi-shell-settings.js";
5
+ import { scrubEnv } from "../src/utils/scrub-env.js";
6
+
7
+ // Strip localterm-managed secret env vars from the agent's bash-tool children.
8
+ // The localterm shim injects each secret only into the shimmed process's env
9
+ // (pi's), not its parent shell — but pi's bash tool spawns commands with
10
+ // { ...process.env }, so without this the agent's commands inherit every secret
11
+ // pi received (`env`, `printenv`, or any script could read a key). This
12
+ // overrides the `bash` tool by name (extensions apply after the built-in in
13
+ // pi's tool registry) with a spawnHook that deletes the pi process's secret
14
+ // envVars from the child env only; pi's own process.env (and its provider
15
+ // calls) keep them. The strip set is recomputed on session_start so a policy
16
+ // change followed by /new, /resume, /fork, or /reload is picked up.
17
+ //
18
+ // This is defense-in-depth, NOT a hard barrier: a determined command can still
19
+ // read the keys via parent-process introspection (`ps eww $PPID` on macOS,
20
+ // `/proc/$PPID/environ` on Linux) or the Keychain directly. For untrusted or
21
+ // unmonitored agents, don't wire secrets to the pi process at all.
22
+ export const registerBashSecretScrub = (pi: ExtensionAPI): void => {
23
+ const cwd = process.cwd();
24
+ const { shellPath, commandPrefix } = readPiShellSettings(cwd);
25
+
26
+ let stripSet = new Set<string>(readLocaltermSecretEnvVarsForPi());
27
+ pi.on("session_start", () => {
28
+ stripSet = new Set(readLocaltermSecretEnvVarsForPi());
29
+ });
30
+
31
+ const spawnHook: BashSpawnHook = ({ command, cwd: spawnCwd, env }) => ({
32
+ command,
33
+ cwd: spawnCwd,
34
+ env: scrubEnv(env, stripSet),
35
+ });
36
+
37
+ pi.registerTool(createBashToolDefinition(cwd, { spawnHook, commandPrefix, shellPath }));
38
+ };
@@ -0,0 +1,16 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerBashSecretScrub } from "./bash-secret-scrub.js";
3
+ import { registerKittyImages } from "./kitty-images.js";
4
+
5
+ // localterm <-> pi integration, inert outside localterm. LOCALTERM=1 is
6
+ // injected into every localterm PTY; when it's absent, nothing is registered
7
+ // and pi behaves exactly as default. Inside localterm this (1) force-enables
8
+ // Kitty graphics + OSC 8 links the xterm.js renderer supports but pi-tui can't
9
+ // detect, and (2) scrubs localterm-managed secret env vars from the agent's
10
+ // bash-tool children so a generated command can't read keys the shim injected
11
+ // into pi's own env.
12
+ export default (pi: ExtensionAPI): void => {
13
+ if (process.env.LOCALTERM !== "1") return;
14
+ registerKittyImages(pi);
15
+ registerBashSecretScrub(pi);
16
+ };
@@ -0,0 +1,18 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getCapabilities, setCapabilities } from "@earendil-works/pi-tui";
3
+
4
+ // localterm renders xterm.js with the Kitty graphics + OSC 8 hyperlink addons
5
+ // loaded, but sets TERM=xterm-256color and strips terminal-identity env vars so
6
+ // Ink TUIs don't probe for a protocol xterm.js lacks. pi-tui therefore reports
7
+ // images/hyperlinks as unsupported. localterm injects LOCALTERM=1 into the PTY
8
+ // env; force-enable the capabilities the rendering layer actually supports so
9
+ // images render instead of falling back to degraded inline text. Skips when
10
+ // images are already enabled (e.g. pi nested inside a real Kitty terminal).
11
+ export const registerKittyImages = (pi: ExtensionAPI): void => {
12
+ pi.on("session_start", async () => {
13
+ if (process.env.LOCALTERM !== "1") return;
14
+ const capabilities = getCapabilities();
15
+ if (capabilities.images) return;
16
+ setCapabilities({ ...capabilities, images: "kitty", hyperlinks: true });
17
+ });
18
+ };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@monotykamary/pi-localterm",
3
+ "version": "0.1.0",
4
+ "description": "localterm <-> pi integration: Kitty graphics for the browser renderer, and scrubbing localterm-managed secret env vars from the agent's bash-tool children.",
5
+ "keywords": [
6
+ "localterm",
7
+ "pi",
8
+ "pi-package",
9
+ "secrets"
10
+ ],
11
+ "homepage": "https://github.com/monotykamary/localterm#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/monotykamary/localterm/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "Aiden Bai",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/monotykamary/localterm.git",
20
+ "directory": "packages/pi-extension"
21
+ },
22
+ "files": [
23
+ "extensions",
24
+ "src"
25
+ ],
26
+ "type": "module",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-coding-agent": "^0.80.2",
32
+ "@earendil-works/pi-tui": "^0.80.2",
33
+ "@types/node": "^26.0.1",
34
+ "typescript": "^6.0.3",
35
+ "vite-plus": "^0.2.1"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "*",
39
+ "@earendil-works/pi-tui": "*"
40
+ },
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "pi": {
45
+ "extensions": [
46
+ "./extensions/index.ts"
47
+ ]
48
+ },
49
+ "scripts": {
50
+ "build": "tsc -p tsconfig.build.json",
51
+ "dev": "tsc -p tsconfig.build.json --watch",
52
+ "test": "vp test --run",
53
+ "typecheck": "tsc -p tsconfig.json --noEmit"
54
+ }
55
+ }
@@ -0,0 +1,24 @@
1
+ // localterm's state directory and the two policy files the scrub reads. These
2
+ // hold names + env vars only — NEVER secret values (values live in the macOS
3
+ // Keychain) — so reading them never touches a key. Mirrors the same constants
4
+ // in localterm-server so the paths stay in lockstep.
5
+ export const LOCALTERM_STATE_DIRNAME = ".localterm";
6
+ export const SECRETS_FILENAME = "secrets.json";
7
+ export const PROCESSES_FILENAME = "processes.json";
8
+
9
+ // The process name localterm wraps with a PATH shim. The scrub strips exactly
10
+ // the secrets this process is wired to, mirroring what the shim injected.
11
+ export const PI_PROCESS_NAME = "pi";
12
+
13
+ // pi's settings file name (global ~/.pi/agent/settings.json + project
14
+ // <cwd>/.pi/settings.json). The bash override reads these to preserve a
15
+ // user's configured shell + command prefix.
16
+ export const PI_SETTINGS_FILENAME = "settings.json";
17
+
18
+ // Canonical validation patterns — mirror localterm-server's zod schemas so a
19
+ // malformed or hostile policy file can never trick the scrub into deleting an
20
+ // unrelated env var. An env var in particular must match the strict uppercase
21
+ // identifier shape a real secret envVar always has.
22
+ export const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
23
+ export const PROCESS_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
24
+ export const ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ ENV_VAR_PATTERN,
6
+ LOCALTERM_STATE_DIRNAME,
7
+ PI_PROCESS_NAME,
8
+ PROCESSES_FILENAME,
9
+ PROCESS_NAME_PATTERN,
10
+ SECRET_NAME_PATTERN,
11
+ SECRETS_FILENAME,
12
+ } from "../constants.js";
13
+
14
+ interface SecretEntry {
15
+ name: string;
16
+ envVar: string;
17
+ }
18
+
19
+ interface ProcessEntry {
20
+ name: string;
21
+ requestedSecrets: string[];
22
+ }
23
+
24
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
25
+ typeof value === "object" && value !== null;
26
+
27
+ const readJsonFile = (filePath: string): unknown => {
28
+ try {
29
+ return JSON.parse(readFileSync(filePath, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+ const parseSecretsFile = (data: unknown): SecretEntry[] => {
36
+ if (!isRecord(data) || !Array.isArray(data.secrets)) return [];
37
+ return data.secrets
38
+ .filter(isRecord)
39
+ .map((entry) => ({ name: String(entry.name ?? ""), envVar: String(entry.envVar ?? "") }))
40
+ .filter((entry) => SECRET_NAME_PATTERN.test(entry.name) && ENV_VAR_PATTERN.test(entry.envVar));
41
+ };
42
+
43
+ const parseProcessesFile = (data: unknown): ProcessEntry[] => {
44
+ if (!isRecord(data) || !Array.isArray(data.processes)) return [];
45
+ return data.processes
46
+ .filter(isRecord)
47
+ .map((entry) => ({
48
+ name: String(entry.name ?? ""),
49
+ requestedSecrets: Array.isArray(entry.requestedSecrets)
50
+ ? entry.requestedSecrets.filter((item): item is string => typeof item === "string")
51
+ : [],
52
+ }))
53
+ .filter((entry) => PROCESS_NAME_PATTERN.test(entry.name));
54
+ };
55
+
56
+ // Resolve the env-var names localterm's shim injected into the `pi` process:
57
+ // the pi process's requestedSecrets (processes.json), each mapped to its
58
+ // envVar (secrets.json). These are the names to strip from pi's bash-tool
59
+ // child env. Reads names + envVars only — NEVER secret values (those live in
60
+ // the Keychain, never in these files). Tolerates missing or malformed files
61
+ // (returns []) so a broken or absent localterm install degrades to a no-op
62
+ // scrub rather than crashing the agent's bash tool. `stateDirectory` defaults
63
+ // to the real ~/.localterm and is overridable for tests.
64
+ export const readLocaltermSecretEnvVarsForPi = (
65
+ stateDirectory: string = join(homedir(), LOCALTERM_STATE_DIRNAME),
66
+ ): string[] => {
67
+ const secretsData = readJsonFile(join(stateDirectory, SECRETS_FILENAME));
68
+ const processesData = readJsonFile(join(stateDirectory, PROCESSES_FILENAME));
69
+
70
+ const envVarBySecretName = new Map<string, string>();
71
+ for (const secret of parseSecretsFile(secretsData)) {
72
+ envVarBySecretName.set(secret.name, secret.envVar);
73
+ }
74
+
75
+ const piProcess = parseProcessesFile(processesData).find(
76
+ (entry) => entry.name === PI_PROCESS_NAME,
77
+ );
78
+ if (!piProcess) return [];
79
+
80
+ const envVars: string[] = [];
81
+ for (const secretName of piProcess.requestedSecrets) {
82
+ if (!SECRET_NAME_PATTERN.test(secretName)) continue;
83
+ const envVar = envVarBySecretName.get(secretName);
84
+ if (envVar) envVars.push(envVar);
85
+ }
86
+ return envVars;
87
+ };
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { PI_SETTINGS_FILENAME } from "../constants.js";
5
+
6
+ interface ShellSettings {
7
+ shellPath: string | undefined;
8
+ commandPrefix: string | undefined;
9
+ }
10
+
11
+ interface ShellSettingsPaths {
12
+ globalSettingsPath?: string;
13
+ configDirName?: string;
14
+ }
15
+
16
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
17
+ typeof value === "object" && value !== null;
18
+
19
+ const readJsonFile = (filePath: string): Record<string, unknown> => {
20
+ try {
21
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
22
+ return isRecord(parsed) ? parsed : {};
23
+ } catch {
24
+ return {};
25
+ }
26
+ };
27
+
28
+ const readNonEmptyString = (settings: Record<string, unknown>, key: string): string | undefined => {
29
+ const value = settings[key];
30
+ return typeof value === "string" && value.length > 0 ? value : undefined;
31
+ };
32
+
33
+ // Resolve the two shell settings pi's built-in bash tool bakes in at
34
+ // construction — `shellPath` (override the shell binary) and
35
+ // `shellCommandPrefix` (prepended to every command, e.g. "shopt -s
36
+ // expand_aliases"). This extension overrides the `bash` tool by name to inject
37
+ // a spawnHook, so it reconstructs the tool — and must pass these through
38
+ // unchanged, or a user who configured them silently loses them. Reads the same
39
+ // two files pi's SettingsManager merges (global ~/.pi/agent/settings.json +
40
+ // project <cwd>/.pi/settings.json); project wins. A shallow merge suffices
41
+ // because both keys are top-level scalars. `paths` is overridable for tests so
42
+ // they never touch the real pi settings.
43
+ export const readPiShellSettings = (cwd: string, paths: ShellSettingsPaths = {}): ShellSettings => {
44
+ const globalSettingsPath = paths.globalSettingsPath ?? join(getAgentDir(), PI_SETTINGS_FILENAME);
45
+ const configDirName = paths.configDirName ?? CONFIG_DIR_NAME;
46
+ const merged: Record<string, unknown> = {
47
+ ...readJsonFile(globalSettingsPath),
48
+ ...readJsonFile(join(cwd, configDirName, PI_SETTINGS_FILENAME)),
49
+ };
50
+ return {
51
+ shellPath: readNonEmptyString(merged, "shellPath"),
52
+ commandPrefix: readNonEmptyString(merged, "shellCommandPrefix"),
53
+ };
54
+ };
@@ -0,0 +1,13 @@
1
+ // Remove the given env-var names from an environment object, returning a new
2
+ // object (the input is never mutated). The bash spawnHook uses this to strip
3
+ // localterm-managed secret env vars from the child process's environment so a
4
+ // command the agent generates cannot read keys the localterm shim injected into
5
+ // pi's own env. Pure: unit-testable without spawning a process.
6
+ export const scrubEnv = (env: NodeJS.ProcessEnv, strip: ReadonlySet<string>): NodeJS.ProcessEnv => {
7
+ if (strip.size === 0) return env;
8
+ const next: NodeJS.ProcessEnv = { ...env };
9
+ for (const name of strip) {
10
+ if (Object.prototype.hasOwnProperty.call(next, name)) delete next[name];
11
+ }
12
+ return next;
13
+ };