my-pi 0.1.19 → 0.1.21
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 +8 -0
- package/dist/{api-D5c50h_J.js → api-SEiGG2V_.js} +47 -80
- package/dist/api-SEiGG2V_.js.map +1 -0
- package/dist/api.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +9 -14
- package/src/extensions/hooks-resolution/index.test.ts +36 -0
- package/src/extensions/hooks-resolution/index.ts +48 -56
- package/src/extensions/hooks-resolution/trust.ts +38 -40
- package/dist/api-D5c50h_J.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Composable pi coding agent with MCP, LSP, prompt presets, and local eval telemetry",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"lsp",
|
|
10
10
|
"mcp",
|
|
11
11
|
"pi",
|
|
12
|
-
"pi-package",
|
|
13
12
|
"sqlite",
|
|
14
13
|
"telemetry"
|
|
15
14
|
],
|
|
@@ -44,18 +43,19 @@
|
|
|
44
43
|
"@mariozechner/pi-tui": "^0.70.6",
|
|
45
44
|
"citty": "^0.2.2",
|
|
46
45
|
"typebox": "^1.1.34",
|
|
47
|
-
"@spences10/pi-lsp": "0.0.
|
|
48
|
-
"@spences10/pi-confirm-destructive": "0.0.5",
|
|
49
|
-
"@spences10/pi-mcp": "0.0.7",
|
|
50
|
-
"@spences10/pi-recall": "0.0.3",
|
|
51
|
-
"@spences10/pi-nopeek": "0.0.3",
|
|
46
|
+
"@spences10/pi-lsp": "0.0.7",
|
|
52
47
|
"@spences10/pi-child-env": "0.1.1",
|
|
48
|
+
"@spences10/pi-nopeek": "0.0.3",
|
|
49
|
+
"@spences10/pi-confirm-destructive": "0.0.5",
|
|
50
|
+
"@spences10/pi-mcp": "0.0.8",
|
|
53
51
|
"@spences10/pi-omnisearch": "0.0.3",
|
|
52
|
+
"@spences10/pi-project-trust": "0.0.2",
|
|
53
|
+
"@spences10/pi-recall": "0.0.3",
|
|
54
54
|
"@spences10/pi-redact": "0.0.3",
|
|
55
55
|
"@spences10/pi-skills": "0.0.4",
|
|
56
|
+
"@spences10/pi-team-mode": "0.0.5",
|
|
56
57
|
"@spences10/pi-sqlite-tools": "0.0.3",
|
|
57
|
-
"@spences10/pi-telemetry": "0.0.3"
|
|
58
|
-
"@spences10/pi-team-mode": "0.0.3"
|
|
58
|
+
"@spences10/pi-telemetry": "0.0.3"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@changesets/cli": "^2.31.0",
|
|
@@ -66,11 +66,6 @@
|
|
|
66
66
|
"engines": {
|
|
67
67
|
"node": ">=22.0.0"
|
|
68
68
|
},
|
|
69
|
-
"pi": {
|
|
70
|
-
"themes": [
|
|
71
|
-
"./themes"
|
|
72
|
-
]
|
|
73
|
-
},
|
|
74
69
|
"scripts": {
|
|
75
70
|
"build": "pnpm --filter \"./packages/*\" run build && vp pack",
|
|
76
71
|
"dev": "vp pack --watch",
|
|
@@ -272,6 +272,42 @@ describe('hooks-resolution extension', () => {
|
|
|
272
272
|
warn.mockRestore();
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
+
it('loads untrusted hook config once when env allows it', async () => {
|
|
276
|
+
const previous = process.env.MY_PI_HOOKS_CONFIG;
|
|
277
|
+
process.env.MY_PI_HOOKS_CONFIG = 'allow';
|
|
278
|
+
try {
|
|
279
|
+
const dir = create_temp_dir();
|
|
280
|
+
mkdirSync(join(dir, '.git'));
|
|
281
|
+
mkdirSync(join(dir, '.pi'));
|
|
282
|
+
writeFileSync(
|
|
283
|
+
join(dir, '.pi', 'hooks.json'),
|
|
284
|
+
JSON.stringify({
|
|
285
|
+
hooks: {
|
|
286
|
+
PostToolUse: [{ command: 'echo allowed' }],
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
const { pi, events } = create_test_pi();
|
|
291
|
+
const load_hooks_impl = vi
|
|
292
|
+
.fn<(cwd: string) => HookState>()
|
|
293
|
+
.mockReturnValue({ project_dir: dir, hooks: [] });
|
|
294
|
+
|
|
295
|
+
await create_hooks_resolution_extension({
|
|
296
|
+
load_hooks: load_hooks_impl,
|
|
297
|
+
})(pi);
|
|
298
|
+
|
|
299
|
+
const start = events.get('session_start');
|
|
300
|
+
const { ctx } = create_context({ cwd: dir, hasUI: false });
|
|
301
|
+
await start({}, ctx);
|
|
302
|
+
|
|
303
|
+
expect(load_hooks_impl).toHaveBeenCalledWith(dir);
|
|
304
|
+
} finally {
|
|
305
|
+
if (previous === undefined)
|
|
306
|
+
delete process.env.MY_PI_HOOKS_CONFIG;
|
|
307
|
+
else process.env.MY_PI_HOOKS_CONFIG = previous;
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
275
311
|
it('runs matching hooks once per unique command and notifies on success', async () => {
|
|
276
312
|
const { pi, events } = create_test_pi();
|
|
277
313
|
const run_command_hook = vi
|
|
@@ -6,14 +6,18 @@ import type {
|
|
|
6
6
|
ExtensionFactory,
|
|
7
7
|
ToolResultEvent,
|
|
8
8
|
} from '@mariozechner/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
resolve_project_trust,
|
|
11
|
+
type ProjectTrustSubject,
|
|
12
|
+
} from '@spences10/pi-project-trust';
|
|
9
13
|
import { spawn } from 'node:child_process';
|
|
10
14
|
import { createHash } from 'node:crypto';
|
|
11
15
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
12
16
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
13
17
|
import { create_child_process_env } from './env.js';
|
|
14
18
|
import {
|
|
19
|
+
default_hooks_trust_store_path,
|
|
15
20
|
is_hooks_config_trusted,
|
|
16
|
-
trust_hooks_config,
|
|
17
21
|
} from './trust.js';
|
|
18
22
|
|
|
19
23
|
const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -526,24 +530,9 @@ export function hook_name(command: string): string {
|
|
|
526
530
|
return basename(first_token);
|
|
527
531
|
}
|
|
528
532
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
value: string | undefined,
|
|
533
|
-
): HooksConfigDecision | undefined {
|
|
534
|
-
const normalized = value?.trim().toLowerCase();
|
|
535
|
-
if (!normalized) return undefined;
|
|
536
|
-
if (['1', 'true', 'yes', 'allow'].includes(normalized)) {
|
|
537
|
-
return 'allow';
|
|
538
|
-
}
|
|
539
|
-
if (normalized === 'trust') return 'trust';
|
|
540
|
-
if (['0', 'false', 'no', 'skip', 'disable'].includes(normalized)) {
|
|
541
|
-
return 'skip';
|
|
542
|
-
}
|
|
543
|
-
return undefined;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function format_hooks_config_prompt(info: HooksConfigInfo): string {
|
|
533
|
+
function create_hooks_trust_subject(
|
|
534
|
+
info: HooksConfigInfo,
|
|
535
|
+
): ProjectTrustSubject {
|
|
547
536
|
const source_lines = info.sources.map((source) => `- ${source}`);
|
|
548
537
|
const hook_lines =
|
|
549
538
|
info.hooks.length === 0
|
|
@@ -554,15 +543,27 @@ function format_hooks_config_prompt(info: HooksConfigInfo): string {
|
|
|
554
543
|
: '';
|
|
555
544
|
return `- ${hook.event_name}${matcher}: ${hook.command}`;
|
|
556
545
|
});
|
|
557
|
-
return
|
|
558
|
-
'
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
546
|
+
return {
|
|
547
|
+
kind: 'hooks-config',
|
|
548
|
+
id: info.project_dir,
|
|
549
|
+
store_key: info.project_dir,
|
|
550
|
+
hash: info.hash,
|
|
551
|
+
env_key: HOOKS_CONFIG_ENV,
|
|
552
|
+
prompt_title:
|
|
553
|
+
'Project hook config can execute shell commands after tool use. Trust these hooks?',
|
|
554
|
+
summary_lines: [
|
|
555
|
+
'Sources:',
|
|
556
|
+
...source_lines,
|
|
557
|
+
'Commands:',
|
|
558
|
+
...hook_lines,
|
|
559
|
+
],
|
|
560
|
+
choices: {
|
|
561
|
+
allow_once: 'Allow once for this session',
|
|
562
|
+
trust: 'Trust this repo until hook config changes',
|
|
563
|
+
skip: 'Skip project hooks',
|
|
564
|
+
},
|
|
565
|
+
headless_warning: `Skipping untrusted hook config in ${info.project_dir}. Set ${HOOKS_CONFIG_ENV}=allow to enable hooks for this run.`,
|
|
566
|
+
};
|
|
566
567
|
}
|
|
567
568
|
|
|
568
569
|
async function should_load_hooks_config(
|
|
@@ -574,36 +575,27 @@ async function should_load_hooks_config(
|
|
|
574
575
|
if (is_hooks_config_trusted(info.project_dir, info.hash))
|
|
575
576
|
return true;
|
|
576
577
|
|
|
577
|
-
const
|
|
578
|
-
|
|
578
|
+
const decision = await resolve_project_trust(
|
|
579
|
+
create_hooks_trust_subject(info),
|
|
580
|
+
{
|
|
581
|
+
has_ui: ctx?.hasUI,
|
|
582
|
+
select: ctx?.hasUI
|
|
583
|
+
? async (
|
|
584
|
+
message: string,
|
|
585
|
+
choices: string[],
|
|
586
|
+
): Promise<string> => {
|
|
587
|
+
const selected = await ctx.ui.select(message, choices);
|
|
588
|
+
return selected ?? '';
|
|
589
|
+
}
|
|
590
|
+
: undefined,
|
|
591
|
+
env: process.env,
|
|
592
|
+
trust_store_path: default_hooks_trust_store_path(),
|
|
593
|
+
},
|
|
579
594
|
);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
if (env_decision === 'allow') return true;
|
|
585
|
-
if (env_decision === 'skip') return false;
|
|
586
|
-
|
|
587
|
-
if (!ctx?.hasUI) {
|
|
588
|
-
console.warn(
|
|
589
|
-
`Skipping untrusted hook config in ${info.project_dir}. Set ${HOOKS_CONFIG_ENV}=allow to enable hooks for this run.`,
|
|
590
|
-
);
|
|
591
|
-
return false;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const choice = await ctx.ui.select(
|
|
595
|
-
format_hooks_config_prompt(info),
|
|
596
|
-
[
|
|
597
|
-
'Allow once for this session',
|
|
598
|
-
'Trust this repo until hook config changes',
|
|
599
|
-
'Skip project hooks',
|
|
600
|
-
],
|
|
595
|
+
return (
|
|
596
|
+
decision.action === 'allow-once' ||
|
|
597
|
+
decision.action === 'trust-persisted'
|
|
601
598
|
);
|
|
602
|
-
if (choice === 'Trust this repo until hook config changes') {
|
|
603
|
-
trust_hooks_config(info.project_dir, info.hash);
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
606
|
-
return choice === 'Allow once for this session';
|
|
607
599
|
}
|
|
608
600
|
|
|
609
601
|
export interface HooksResolutionOptions {
|
|
@@ -1,32 +1,52 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from '
|
|
2
|
+
is_project_subject_trusted,
|
|
3
|
+
read_project_trust_store,
|
|
4
|
+
trust_project_subject,
|
|
5
|
+
type ProjectTrustSubject,
|
|
6
|
+
} from '@spences10/pi-project-trust';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
-
import {
|
|
8
|
+
import { join } from 'node:path';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
project_dir: string;
|
|
12
|
-
hash: string;
|
|
13
|
-
trusted_at: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type TrustedHooks = Record<string, TrustedHooksEntry>;
|
|
10
|
+
const HOOKS_CONFIG_ENV = 'MY_PI_HOOKS_CONFIG';
|
|
17
11
|
|
|
18
12
|
export function default_hooks_trust_store_path(): string {
|
|
19
13
|
return join(homedir(), '.pi', 'agent', 'trusted-hooks.json');
|
|
20
14
|
}
|
|
21
15
|
|
|
16
|
+
export function create_hooks_config_trust_subject(
|
|
17
|
+
project_dir: string,
|
|
18
|
+
hash: string,
|
|
19
|
+
): ProjectTrustSubject {
|
|
20
|
+
return {
|
|
21
|
+
kind: 'hooks-config',
|
|
22
|
+
id: project_dir,
|
|
23
|
+
store_key: project_dir,
|
|
24
|
+
hash,
|
|
25
|
+
env_key: HOOKS_CONFIG_ENV,
|
|
26
|
+
prompt_title:
|
|
27
|
+
'Project hook config can execute shell commands after tool use. Trust these hooks?',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
export function is_hooks_config_trusted(
|
|
23
32
|
project_dir: string,
|
|
24
33
|
hash: string,
|
|
25
34
|
trust_store_path = default_hooks_trust_store_path(),
|
|
26
35
|
): boolean {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
const subject = create_hooks_config_trust_subject(
|
|
37
|
+
project_dir,
|
|
38
|
+
hash,
|
|
39
|
+
);
|
|
40
|
+
if (is_project_subject_trusted(subject, trust_store_path))
|
|
41
|
+
return true;
|
|
42
|
+
|
|
43
|
+
const legacy_entry = read_project_trust_store(trust_store_path)[
|
|
44
|
+
project_dir
|
|
45
|
+
] as { project_dir?: unknown; hash?: unknown } | undefined;
|
|
46
|
+
return (
|
|
47
|
+
legacy_entry?.project_dir === project_dir &&
|
|
48
|
+
legacy_entry.hash === hash
|
|
49
|
+
);
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
export function trust_hooks_config(
|
|
@@ -34,30 +54,8 @@ export function trust_hooks_config(
|
|
|
34
54
|
hash: string,
|
|
35
55
|
trust_store_path = default_hooks_trust_store_path(),
|
|
36
56
|
): void {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
project_dir,
|
|
40
|
-
hash,
|
|
41
|
-
trusted_at: new Date().toISOString(),
|
|
42
|
-
};
|
|
43
|
-
mkdirSync(dirname(trust_store_path), { recursive: true });
|
|
44
|
-
writeFileSync(
|
|
57
|
+
trust_project_subject(
|
|
58
|
+
create_hooks_config_trust_subject(project_dir, hash),
|
|
45
59
|
trust_store_path,
|
|
46
|
-
JSON.stringify(trusted_hooks, null, '\t') + '\n',
|
|
47
|
-
{
|
|
48
|
-
encoding: 'utf8',
|
|
49
|
-
mode: 0o600,
|
|
50
|
-
},
|
|
51
60
|
);
|
|
52
61
|
}
|
|
53
|
-
|
|
54
|
-
function read_trusted_hooks(trust_store_path: string): TrustedHooks {
|
|
55
|
-
if (!existsSync(trust_store_path)) return {};
|
|
56
|
-
try {
|
|
57
|
-
const raw = readFileSync(trust_store_path, 'utf-8');
|
|
58
|
-
const parsed = JSON.parse(raw) as TrustedHooks;
|
|
59
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
60
|
-
} catch {
|
|
61
|
-
return {};
|
|
62
|
-
}
|
|
63
|
-
}
|