my-pi 0.1.11 → 0.1.13

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 CHANGED
@@ -195,9 +195,29 @@ A practical sandbox command looks like:
195
195
  ```bash
196
196
  PI_CODING_AGENT_DIR=/work/pi-agent \
197
197
  ANTHROPIC_API_KEY=... \
198
- pnpx my-pi@latest --telemetry --json "run eval case"
198
+ pnpx my-pi@latest --untrusted --telemetry --json "run eval case"
199
199
  ```
200
200
 
201
+ ### Untrusted repo safe mode
202
+
203
+ Use `--untrusted` in unknown repositories, evals, or sandboxes. It
204
+ keeps built-ins available but starts with conservative
205
+ project-resource defaults:
206
+
207
+ - skips project-local MCP config (`MY_PI_MCP_PROJECT_CONFIG=skip`)
208
+ - skips Claude-style project hooks (`MY_PI_HOOKS_CONFIG=skip`)
209
+ - uses global LSP binaries instead of project-local binaries
210
+ (`MY_PI_LSP_PROJECT_BINARY=global`)
211
+ - skips project prompt presets (`MY_PI_PROMPT_PRESETS_PROJECT=skip`)
212
+ - skips project-local `.pi/skills` and `.claude/skills`
213
+ (`MY_PI_PROJECT_SKILLS=skip`)
214
+ - clears optional child-process env allowlists unless they were set
215
+ explicitly
216
+
217
+ Set the listed environment variables to `allow` or `trust` where
218
+ supported to re-enable one feature intentionally while staying in safe
219
+ mode.
220
+
201
221
  ### Extension stacking
202
222
 
203
223
  ```bash
@@ -313,8 +333,28 @@ HTTP MCP servers are supported too:
313
333
  Use `"type": "http"` or `"type": "streamable-http"` for remote MCP
314
334
  servers. If `url` is present, my-pi treats the entry as HTTP.
315
335
 
316
- Project servers merge with global servers. If both define the same
317
- server name, the project config wins.
336
+ Global MCP config is loaded automatically. Project-local `mcp.json` is
337
+ untrusted by default; interactive sessions prompt before loading it
338
+ and headless sessions skip it unless `MY_PI_MCP_PROJECT_CONFIG=allow`
339
+ or `MY_PI_MCP_PROJECT_CONFIG=trust` is set. If both configs define the
340
+ same server name, the trusted project config wins.
341
+
342
+ ### Hooks
343
+
344
+ Claude-style hooks are discovered from `.claude/settings.json`,
345
+ `.rulesync/hooks.json`, and `.pi/hooks.json`. Because hook commands
346
+ run through `bash -lc`, project hook config is untrusted by default.
347
+ Interactive sessions show the hook source files and commands before
348
+ allowing execution; headless sessions skip hooks unless
349
+ `MY_PI_HOOKS_CONFIG=allow` or `MY_PI_HOOKS_CONFIG=trust` is set.
350
+ Trusted hook approvals are remembered per project directory and
351
+ hook-config hash.
352
+
353
+ Hook commands receive a restricted child-process environment by
354
+ default: baseline shell variables plus `CLAUDE_PROJECT_DIR`. Use
355
+ `MY_PI_HOOKS_ENV_ALLOWLIST=NAME,OTHER_NAME` or the shared
356
+ `MY_PI_CHILD_ENV_ALLOWLIST` to pass selected ambient variables
357
+ through.
318
358
 
319
359
  ### Commands
320
360
 
@@ -1,6 +1,6 @@
1
1
  import { BorderedLoader, InteractiveMode as InteractiveMode$1, SessionManager, SettingsManager, convertToLlm, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, runPrintMode as runPrintMode$1, serializeConversation } from "@mariozechner/pi-coding-agent";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import confirm_destructive_extension from "@spences10/pi-confirm-destructive";
6
6
  import lsp_extension from "@spences10/pi-lsp";
@@ -13,11 +13,81 @@ import skills_extension, { create_skills_manager } from "@spences10/pi-skills";
13
13
  import sqlite_tools_extension from "@spences10/pi-sqlite-tools";
14
14
  import { create_telemetry_extension } from "@spences10/pi-telemetry";
15
15
  import { spawn } from "node:child_process";
16
+ import { createHash } from "node:crypto";
16
17
  import { homedir } from "node:os";
17
18
  import { Container, SettingsList, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
18
19
  import { complete } from "@mariozechner/pi-ai";
20
+ //#region src/extensions/hooks-resolution/env.ts
21
+ const BASE_CHILD_ENV_KEYS = new Set([
22
+ "CI",
23
+ "COLORTERM",
24
+ "FORCE_COLOR",
25
+ "HOME",
26
+ "LANG",
27
+ "LOGNAME",
28
+ "NO_COLOR",
29
+ "PATH",
30
+ "SHELL",
31
+ "TEMP",
32
+ "TERM",
33
+ "TMP",
34
+ "TMPDIR",
35
+ "USER"
36
+ ]);
37
+ const EXTRA_ENV_ALLOWLIST_KEYS = ["MY_PI_CHILD_ENV_ALLOWLIST", "MY_PI_HOOKS_ENV_ALLOWLIST"];
38
+ function create_child_process_env(explicit_env = {}, source_env = process.env) {
39
+ const env = {};
40
+ const allowed_keys = new Set(BASE_CHILD_ENV_KEYS);
41
+ for (const key of Object.keys(source_env)) if (key.startsWith("LC_")) allowed_keys.add(key);
42
+ for (const allowlist_key of EXTRA_ENV_ALLOWLIST_KEYS) for (const key of parse_env_allowlist(source_env[allowlist_key])) allowed_keys.add(key);
43
+ for (const key of allowed_keys) {
44
+ const value = source_env[key];
45
+ if (typeof value === "string") env[key] = value;
46
+ }
47
+ return {
48
+ ...env,
49
+ ...explicit_env
50
+ };
51
+ }
52
+ function parse_env_allowlist(value) {
53
+ if (!value) return [];
54
+ return value.split(",").map((key) => key.trim()).filter(Boolean);
55
+ }
56
+ //#endregion
57
+ //#region src/extensions/hooks-resolution/trust.ts
58
+ function default_hooks_trust_store_path() {
59
+ return join(homedir(), ".pi", "agent", "trusted-hooks.json");
60
+ }
61
+ function is_hooks_config_trusted(project_dir, hash, trust_store_path = default_hooks_trust_store_path()) {
62
+ return read_trusted_hooks(trust_store_path)[project_dir]?.hash === hash;
63
+ }
64
+ function trust_hooks_config(project_dir, hash, trust_store_path = default_hooks_trust_store_path()) {
65
+ const trusted_hooks = read_trusted_hooks(trust_store_path);
66
+ trusted_hooks[project_dir] = {
67
+ project_dir,
68
+ hash,
69
+ trusted_at: (/* @__PURE__ */ new Date()).toISOString()
70
+ };
71
+ mkdirSync(dirname(trust_store_path), { recursive: true });
72
+ writeFileSync(trust_store_path, JSON.stringify(trusted_hooks, null, " ") + "\n", {
73
+ encoding: "utf8",
74
+ mode: 384
75
+ });
76
+ }
77
+ function read_trusted_hooks(trust_store_path) {
78
+ if (!existsSync(trust_store_path)) return {};
79
+ try {
80
+ const raw = readFileSync(trust_store_path, "utf-8");
81
+ const parsed = JSON.parse(raw);
82
+ return parsed && typeof parsed === "object" ? parsed : {};
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+ //#endregion
19
88
  //#region src/extensions/hooks-resolution/index.ts
20
89
  const HOOK_TIMEOUT_MS = 600 * 1e3;
90
+ const HOOKS_CONFIG_ENV = "MY_PI_HOOKS_CONFIG";
21
91
  function is_file(path) {
22
92
  try {
23
93
  return statSync(path).isFile();
@@ -134,20 +204,47 @@ function parse_simple_hooks_file(config, source, project_dir) {
134
204
  }
135
205
  return hooks;
136
206
  }
207
+ function hook_config_paths(project_dir) {
208
+ return [
209
+ join(project_dir, ".claude", "settings.json"),
210
+ join(project_dir, ".rulesync", "hooks.json"),
211
+ join(project_dir, ".pi", "hooks.json")
212
+ ];
213
+ }
214
+ function parse_hooks_config_file(path, project_dir) {
215
+ const config = read_json_file(path);
216
+ if (config === void 0) return [];
217
+ if (path.endsWith(join(".claude", "settings.json"))) return parse_claude_settings_hooks(config, path, project_dir);
218
+ return parse_simple_hooks_file(config, path, project_dir);
219
+ }
137
220
  function load_hooks(cwd) {
138
221
  const project_dir = find_project_dir(cwd);
139
- const hooks = [];
140
- const claude_settings_path = join(project_dir, ".claude", "settings.json");
141
- const rulesync_hooks_path = join(project_dir, ".rulesync", "hooks.json");
142
- const pi_hooks_path = join(project_dir, ".pi", "hooks.json");
143
- const claude_settings = read_json_file(claude_settings_path);
144
- if (claude_settings !== void 0) hooks.push(...parse_claude_settings_hooks(claude_settings, claude_settings_path, project_dir));
145
- const rulesync_hooks = read_json_file(rulesync_hooks_path);
146
- if (rulesync_hooks !== void 0) hooks.push(...parse_simple_hooks_file(rulesync_hooks, rulesync_hooks_path, project_dir));
147
- const pi_hooks = read_json_file(pi_hooks_path);
148
- if (pi_hooks !== void 0) hooks.push(...parse_simple_hooks_file(pi_hooks, pi_hooks_path, project_dir));
149
222
  return {
150
223
  project_dir,
224
+ hooks: hook_config_paths(project_dir).flatMap((path) => parse_hooks_config_file(path, project_dir))
225
+ };
226
+ }
227
+ function get_hooks_config_info(cwd) {
228
+ const project_dir = find_project_dir(cwd);
229
+ const sources = hook_config_paths(project_dir).filter(is_file);
230
+ if (sources.length === 0) return void 0;
231
+ const hash = createHash("sha256");
232
+ for (const source of sources) {
233
+ hash.update(source);
234
+ hash.update("\0");
235
+ hash.update(readFileSync(source, "utf8"));
236
+ hash.update("\0");
237
+ }
238
+ const hooks = sources.flatMap((source) => parse_hooks_config_file(source, project_dir)).map((hook) => ({
239
+ event_name: hook.event_name,
240
+ matcher_text: hook.matcher_text,
241
+ command: hook.command,
242
+ source: hook.source
243
+ }));
244
+ return {
245
+ project_dir,
246
+ hash: hash.digest("hex"),
247
+ sources,
151
248
  hooks
152
249
  };
153
250
  }
@@ -216,10 +313,7 @@ async function run_command_hook(command, cwd, payload) {
216
313
  const started_at = Date.now();
217
314
  const child = spawn("bash", ["-lc", command], {
218
315
  cwd,
219
- env: {
220
- ...process.env,
221
- CLAUDE_PROJECT_DIR: cwd
222
- },
316
+ env: create_child_process_env({ CLAUDE_PROJECT_DIR: cwd }),
223
317
  stdio: [
224
318
  "pipe",
225
319
  "pipe",
@@ -284,6 +378,66 @@ function hook_name(command) {
284
378
  if (sh_path_match) return basename(sh_path_match[0]);
285
379
  return basename(command.trim().split(/\s+/)[0] ?? "hook");
286
380
  }
381
+ function normalize_hooks_config_decision(value) {
382
+ const normalized = value?.trim().toLowerCase();
383
+ if (!normalized) return void 0;
384
+ if ([
385
+ "1",
386
+ "true",
387
+ "yes",
388
+ "allow"
389
+ ].includes(normalized)) return "allow";
390
+ if (normalized === "trust") return "trust";
391
+ if ([
392
+ "0",
393
+ "false",
394
+ "no",
395
+ "skip",
396
+ "disable"
397
+ ].includes(normalized)) return "skip";
398
+ }
399
+ function format_hooks_config_prompt(info) {
400
+ const source_lines = info.sources.map((source) => `- ${source}`);
401
+ const hook_lines = info.hooks.length === 0 ? ["- no valid command hooks detected"] : info.hooks.map((hook) => {
402
+ const matcher = hook.matcher_text ? ` matcher=${hook.matcher_text}` : "";
403
+ return `- ${hook.event_name}${matcher}: ${hook.command}`;
404
+ });
405
+ return [
406
+ "Project hook config can execute shell commands after tool use. Trust these hooks?",
407
+ `Project: ${info.project_dir}`,
408
+ `SHA-256: ${info.hash}`,
409
+ "Sources:",
410
+ ...source_lines,
411
+ "Commands:",
412
+ ...hook_lines
413
+ ].join("\n");
414
+ }
415
+ async function should_load_hooks_config(cwd, ctx) {
416
+ const info = get_hooks_config_info(cwd);
417
+ if (!info) return true;
418
+ if (is_hooks_config_trusted(info.project_dir, info.hash)) return true;
419
+ const env_decision = normalize_hooks_config_decision(process.env[HOOKS_CONFIG_ENV]);
420
+ if (env_decision === "trust") {
421
+ trust_hooks_config(info.project_dir, info.hash);
422
+ return true;
423
+ }
424
+ if (env_decision === "allow") return true;
425
+ if (env_decision === "skip") return false;
426
+ if (!ctx?.hasUI) {
427
+ console.warn(`Skipping untrusted hook config in ${info.project_dir}. Set ${HOOKS_CONFIG_ENV}=allow to enable hooks for this run.`);
428
+ return false;
429
+ }
430
+ const choice = await ctx.ui.select(format_hooks_config_prompt(info), [
431
+ "Allow once for this session",
432
+ "Trust this repo until hook config changes",
433
+ "Skip project hooks"
434
+ ]);
435
+ if (choice === "Trust this repo until hook config changes") {
436
+ trust_hooks_config(info.project_dir, info.hash);
437
+ return true;
438
+ }
439
+ return choice === "Allow once for this session";
440
+ }
287
441
  function create_hooks_resolution_extension(options = {}) {
288
442
  const load_hooks_impl = options.load_hooks ?? load_hooks;
289
443
  const run_command_hook_impl = options.run_command_hook ?? run_command_hook;
@@ -292,11 +446,18 @@ function create_hooks_resolution_extension(options = {}) {
292
446
  project_dir: process.cwd(),
293
447
  hooks: []
294
448
  };
295
- const refresh_hooks = (cwd) => {
449
+ const refresh_hooks = async (cwd, ctx) => {
450
+ if (!await should_load_hooks_config(cwd, ctx)) {
451
+ state = {
452
+ project_dir: cwd,
453
+ hooks: []
454
+ };
455
+ return;
456
+ }
296
457
  state = load_hooks_impl(cwd);
297
458
  };
298
- pi.on("session_start", (_event, ctx) => {
299
- refresh_hooks(ctx.cwd);
459
+ pi.on("session_start", async (_event, ctx) => {
460
+ await refresh_hooks(ctx.cwd, ctx);
300
461
  });
301
462
  pi.on("tool_result", async (event, ctx) => {
302
463
  if (state.hooks.length === 0) return;
@@ -706,6 +867,7 @@ function create_extensions_extension(options = {}) {
706
867
  create_extensions_extension();
707
868
  //#endregion
708
869
  //#region src/extensions/prompt-presets/index.ts
870
+ const PROJECT_PROMPT_PRESETS_ENV = "MY_PI_PROMPT_PRESETS_PROJECT";
709
871
  const PRESET_STATE_TYPE = "prompt-preset-state";
710
872
  const ENABLED = "[x]";
711
873
  const DISABLED = "[ ]";
@@ -891,8 +1053,18 @@ function save_project_prompt_preset_file(cwd, name, preset) {
891
1053
  function save_global_prompt_preset_file(name, preset) {
892
1054
  return save_prompt_preset_file(get_global_presets_dir(), name, preset);
893
1055
  }
1056
+ function should_load_project_prompt_presets() {
1057
+ const normalized = process.env[PROJECT_PROMPT_PRESETS_ENV]?.trim().toLowerCase();
1058
+ return ![
1059
+ "0",
1060
+ "false",
1061
+ "no",
1062
+ "skip",
1063
+ "disable"
1064
+ ].includes(normalized ?? "");
1065
+ }
894
1066
  function load_prompt_presets(cwd) {
895
- return Object.assign({}, to_loaded_prompt_presets(DEFAULT_PROMPT_PRESETS, "builtin"), to_loaded_prompt_presets(read_prompt_presets_file(get_global_presets_path()), "user"), to_loaded_prompt_presets(read_prompt_presets_dir(get_global_presets_dir()), "user"), to_loaded_prompt_presets(read_prompt_presets_file(get_project_presets_path(cwd)), "project"), to_loaded_prompt_presets(read_prompt_presets_dir(get_project_presets_dir(cwd)), "project"));
1067
+ return Object.assign({}, to_loaded_prompt_presets(DEFAULT_PROMPT_PRESETS, "builtin"), to_loaded_prompt_presets(read_prompt_presets_file(get_global_presets_path()), "user"), to_loaded_prompt_presets(read_prompt_presets_dir(get_global_presets_dir()), "user"), ...should_load_project_prompt_presets() ? [to_loaded_prompt_presets(read_prompt_presets_file(get_project_presets_path(cwd)), "project"), to_loaded_prompt_presets(read_prompt_presets_dir(get_project_presets_dir(cwd)), "project")] : []);
896
1068
  }
897
1069
  function sort_prompt_presets(presets) {
898
1070
  return Object.fromEntries(Object.entries(presets).sort(([a], [b]) => a.localeCompare(b)));
@@ -1843,6 +2015,44 @@ const BUILTIN_EXTENSION_FACTORIES = {
1843
2015
  };
1844
2016
  const PACKAGE_THEME_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "themes");
1845
2017
  const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
2018
+ const UNTRUSTED_REPO_ENV_DEFAULTS = {
2019
+ MY_PI_MCP_PROJECT_CONFIG: "skip",
2020
+ MY_PI_HOOKS_CONFIG: "skip",
2021
+ MY_PI_LSP_PROJECT_BINARY: "global",
2022
+ MY_PI_PROMPT_PRESETS_PROJECT: "skip",
2023
+ MY_PI_PROJECT_SKILLS: "skip",
2024
+ MY_PI_CHILD_ENV_ALLOWLIST: "",
2025
+ MY_PI_MCP_ENV_ALLOWLIST: "",
2026
+ MY_PI_HOOKS_ENV_ALLOWLIST: ""
2027
+ };
2028
+ function apply_untrusted_repo_defaults(env = process.env) {
2029
+ const applied = [];
2030
+ for (const [key, value] of Object.entries(UNTRUSTED_REPO_ENV_DEFAULTS)) {
2031
+ if (env[key] !== void 0) continue;
2032
+ env[key] = value;
2033
+ applied.push(key);
2034
+ }
2035
+ return applied;
2036
+ }
2037
+ function is_resource_enabled(value) {
2038
+ const normalized = value?.trim().toLowerCase();
2039
+ if (!normalized) return true;
2040
+ if ([
2041
+ "0",
2042
+ "false",
2043
+ "no",
2044
+ "skip",
2045
+ "disable"
2046
+ ].includes(normalized)) return false;
2047
+ return true;
2048
+ }
2049
+ function is_project_local_skill_path(cwd, file_path) {
2050
+ if (!file_path) return false;
2051
+ const relative_path = relative(cwd, resolve(cwd, file_path));
2052
+ if (!relative_path || relative_path.startsWith("..") || isAbsolute(relative_path)) return false;
2053
+ const parts = relative_path.split(/[\\/]+/);
2054
+ return parts.some((part, index) => (part === ".pi" || part === ".claude") && parts[index + 1] === "skills");
2055
+ }
1846
2056
  function resolve_agent_dir(cwd, agent_dir) {
1847
2057
  return agent_dir ? resolve(cwd, agent_dir) : getAgentDir();
1848
2058
  }
@@ -1883,7 +2093,8 @@ function create_extensions_override(managed_inline_paths) {
1883
2093
  };
1884
2094
  }
1885
2095
  async function create_my_pi(options = {}) {
1886
- const { cwd = process.cwd(), agent_dir, extensions = [], extensionFactories: user_factories = [], runtime_mode = "interactive", mcp = true, skills = true, filter_output = true, recall = true, nopeek = true, omnisearch = true, sqlite_tools = true, prompt_presets = true, lsp = true, session_name = true, confirm_destructive = true, hooks_resolution = true, telemetry, telemetry_db_path, model, system_prompt, append_system_prompt } = options;
2096
+ const { cwd = process.cwd(), agent_dir, extensions = [], extensionFactories: user_factories = [], runtime_mode = "interactive", mcp = true, skills = true, filter_output = true, recall = true, nopeek = true, omnisearch = true, sqlite_tools = true, prompt_presets = true, lsp = true, session_name = true, confirm_destructive = true, hooks_resolution = true, telemetry, telemetry_db_path, model, system_prompt, append_system_prompt, untrusted_repo = false } = options;
2097
+ if (untrusted_repo) apply_untrusted_repo_defaults();
1887
2098
  const effective_agent_dir = resolve_agent_dir(cwd, agent_dir);
1888
2099
  if (agent_dir) process.env[PI_AGENT_DIR_ENV] = effective_agent_dir;
1889
2100
  const resolved_extensions = extensions.map((p) => resolve(cwd, p));
@@ -1931,10 +2142,14 @@ async function create_my_pi(options = {}) {
1931
2142
  extensionsOverride: create_extensions_override(managed_inline_paths),
1932
2143
  skillsOverride: (base) => {
1933
2144
  if (!is_builtin_extension_active(load_builtin_extensions_config(), "skills", force_disabled)) return base;
2145
+ const include_project_skills = is_resource_enabled(process.env.MY_PI_PROJECT_SKILLS);
1934
2146
  const skills_manager = create_skills_manager();
1935
2147
  return {
1936
2148
  ...base,
1937
- skills: base.skills.filter((skill) => skills_manager.is_enabled_by_skill(skill.name, skill.filePath))
2149
+ skills: base.skills.filter((skill) => {
2150
+ if (!include_project_skills && is_project_local_skill_path(runtime_cwd, skill.filePath)) return false;
2151
+ return skills_manager.is_enabled_by_skill(skill.name, skill.filePath);
2152
+ })
1938
2153
  };
1939
2154
  }
1940
2155
  }
@@ -1956,6 +2171,6 @@ async function create_my_pi(options = {}) {
1956
2171
  });
1957
2172
  }
1958
2173
  //#endregion
1959
- export { runPrintMode$1 as i, create_my_pi as n, get_force_disabled_builtins as r, InteractiveMode$1 as t };
2174
+ export { is_project_local_skill_path as a, get_force_disabled_builtins as i, apply_untrusted_repo_defaults as n, runPrintMode$1 as o, create_my_pi as r, InteractiveMode$1 as t };
1960
2175
 
1961
- //# sourceMappingURL=api-C7cSSbTg.js.map
2176
+ //# sourceMappingURL=api-Eq36fWnN.js.map