my-pi 0.1.11 → 0.1.12

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
@@ -313,8 +313,28 @@ HTTP MCP servers are supported too:
313
313
  Use `"type": "http"` or `"type": "streamable-http"` for remote MCP
314
314
  servers. If `url` is present, my-pi treats the entry as HTTP.
315
315
 
316
- Project servers merge with global servers. If both define the same
317
- server name, the project config wins.
316
+ Global MCP config is loaded automatically. Project-local `mcp.json` is
317
+ untrusted by default; interactive sessions prompt before loading it
318
+ and headless sessions skip it unless `MY_PI_MCP_PROJECT_CONFIG=allow`
319
+ or `MY_PI_MCP_PROJECT_CONFIG=trust` is set. If both configs define the
320
+ same server name, the trusted project config wins.
321
+
322
+ ### Hooks
323
+
324
+ Claude-style hooks are discovered from `.claude/settings.json`,
325
+ `.rulesync/hooks.json`, and `.pi/hooks.json`. Because hook commands
326
+ run through `bash -lc`, project hook config is untrusted by default.
327
+ Interactive sessions show the hook source files and commands before
328
+ allowing execution; headless sessions skip hooks unless
329
+ `MY_PI_HOOKS_CONFIG=allow` or `MY_PI_HOOKS_CONFIG=trust` is set.
330
+ Trusted hook approvals are remembered per project directory and
331
+ hook-config hash.
332
+
333
+ Hook commands receive a restricted child-process environment by
334
+ default: baseline shell variables plus `CLAUDE_PROJECT_DIR`. Use
335
+ `MY_PI_HOOKS_ENV_ALLOWLIST=NAME,OTHER_NAME` or the shared
336
+ `MY_PI_CHILD_ENV_ALLOWLIST` to pass selected ambient variables
337
+ through.
318
338
 
319
339
  ### Commands
320
340
 
@@ -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;
@@ -1958,4 +2119,4 @@ async function create_my_pi(options = {}) {
1958
2119
  //#endregion
1959
2120
  export { runPrintMode$1 as i, create_my_pi as n, get_force_disabled_builtins as r, InteractiveMode$1 as t };
1960
2121
 
1961
- //# sourceMappingURL=api-C7cSSbTg.js.map
2122
+ //# sourceMappingURL=api-CB0OXSW_.js.map