@gotgenes/pi-permission-system 7.3.3 → 7.4.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [7.4.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.0...pi-permission-system-v7.4.1) (2026-05-30)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** resolve bash paths against leading cd target ([c655a7e](https://github.com/gotgenes/pi-packages/commit/c655a7e737aeac9a8f10909804260c65d339c8b7))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document cd-aware bash path resolution ([a2e6541](https://github.com/gotgenes/pi-packages/commit/a2e65410e89eb1e62579078c16af05aead013603))
19
+
20
+ ## [7.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.3...pi-permission-system-v7.4.0) (2026-05-29)
21
+
22
+
23
+ ### Features
24
+
25
+ * register subagent child sessions via lifecycle events ([cd324dc](https://github.com/gotgenes/pi-packages/commit/cd324dc5f8b18fe69ba8802eda0b17a6a36ccc58))
26
+
27
+
28
+ ### Documentation
29
+
30
+ * document event-based subagent child lifecycle ([62621fa](https://github.com/gotgenes/pi-packages/commit/62621fa9abd093b5deadb3c15139179ae85ad519))
31
+
8
32
  ## [7.3.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.2...pi-permission-system-v7.3.3) (2026-05-28)
9
33
 
10
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.3.3",
3
+ "version": "7.4.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,8 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
- import { basename } from "node:path";
2
+ import { basename, resolve } from "node:path";
3
3
 
4
4
  import {
5
- isPathOutsideWorkingDirectory,
5
+ isPathWithinDirectory,
6
+ isSafeSystemPath,
6
7
  normalizePathForComparison,
7
8
  } from "#src/path-utils";
8
9
 
@@ -501,11 +502,90 @@ function classifyTokenAsPathCandidate(token: string): string | null {
501
502
  return null;
502
503
  }
503
504
 
505
+ // ── Leading cd detection ───────────────────────────────────────────────────
506
+
507
+ /**
508
+ * Walk down from the root to find the first `command` node in the program.
509
+ *
510
+ * Only descends into `program` and `list` nodes — subshells, pipelines, and
511
+ * other compound statements are ignored because a `cd` inside them does not
512
+ * affect the outer shell's working directory.
513
+ */
514
+ function findFirstCommand(node: TSNode): TSNode | null {
515
+ if (node.type === "command") return node;
516
+ if (node.type === "program" || node.type === "list") {
517
+ const firstChild = node.child(0);
518
+ if (firstChild) return findFirstCommand(firstChild);
519
+ }
520
+ return null;
521
+ }
522
+
523
+ /**
524
+ * Extract the target directory of a leading `cd` command from the parsed AST.
525
+ *
526
+ * When a bash command begins with `cd <dir> && …`, the shell resolves
527
+ * subsequent relative paths against `<dir>`, not the original working
528
+ * directory. The external-directory guard must do the same, otherwise a
529
+ * path that the shell keeps inside the working directory can appear to
530
+ * escape it and trigger a spurious permission prompt.
531
+ *
532
+ * Returns `undefined` when the first command is not `cd`, or when the
533
+ * target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
534
+ */
535
+ function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
536
+ const firstCmd = findFirstCommand(rootNode);
537
+ if (!firstCmd) return undefined;
538
+
539
+ const cmdName = extractCommandName(firstCmd);
540
+ if (cmdName !== "cd") return undefined;
541
+
542
+ for (let i = 0; i < firstCmd.childCount; i++) {
543
+ const child = firstCmd.child(i);
544
+ if (!child) continue;
545
+ if (child.type === "command_name" || child.type === "variable_assignment")
546
+ continue;
547
+ if (!ARG_NODE_TYPES.has(child.type)) continue;
548
+
549
+ const text = resolveNodeText(child);
550
+ // Skip `--` (end-of-flags marker)
551
+ if (text === "--") continue;
552
+ // `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
553
+ // resolved against the working directory.
554
+ if (text === "-" || text.startsWith("~")) return undefined;
555
+ return text;
556
+ }
557
+ return undefined;
558
+ }
559
+
560
+ /**
561
+ * Compute the effective base directory for resolving relative path candidates.
562
+ *
563
+ * When the leading `cd` target stays within the working directory, subsequent
564
+ * relative paths should be resolved against it. An escaping target is itself
565
+ * an external access (reported via its own candidate token) and must never
566
+ * silence checks on subsequent paths, so the function falls back to `cwd`.
567
+ */
568
+ function computeEffectiveResolveBase(
569
+ cdTarget: string | undefined,
570
+ cwd: string,
571
+ ): string {
572
+ if (cdTarget === undefined) return cwd;
573
+ const resolved = resolve(cwd, cdTarget);
574
+ const normalizedCwd = resolve(cwd);
575
+ return isPathWithinDirectory(resolved, normalizedCwd) ? resolved : cwd;
576
+ }
577
+
578
+ // ── Public extractors ──────────────────────────────────────────────────────
579
+
504
580
  /**
505
581
  * Extracts paths from a bash command string that resolve outside CWD.
506
582
  * Uses tree-sitter-bash to parse the command into a full AST, then walks
507
583
  * command argument and redirect-destination nodes. Heredoc bodies, comments,
508
584
  * and other non-argument content are skipped, eliminating false positives.
585
+ *
586
+ * When the command begins with `cd <dir> && …`, relative candidate paths are
587
+ * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
588
+ * mirroring how the shell would resolve them.
509
589
  */
510
590
  export async function extractExternalPathsFromBashCommand(
511
591
  command: string,
@@ -515,13 +595,18 @@ export async function extractExternalPathsFromBashCommand(
515
595
  const tree = parser.parse(command);
516
596
  if (!tree) return [];
517
597
 
598
+ let cdTarget: string | undefined;
518
599
  const tokens: string[] = [];
519
600
  try {
601
+ cdTarget = extractLeadingCdTarget(tree.rootNode);
520
602
  collectPathCandidateTokens(tree.rootNode, tokens);
521
603
  } finally {
522
604
  tree.delete();
523
605
  }
524
606
 
607
+ const resolveBase = computeEffectiveResolveBase(cdTarget, cwd);
608
+ const normalizedCwd = normalizePathForComparison(cwd, cwd);
609
+
525
610
  const seen = new Set<string>();
526
611
  const externalPaths: string[] = [];
527
612
 
@@ -529,11 +614,13 @@ export async function extractExternalPathsFromBashCommand(
529
614
  const candidate = classifyTokenAsPathCandidate(token);
530
615
  if (!candidate) continue;
531
616
 
532
- const normalized = normalizePathForComparison(candidate, cwd);
617
+ const normalized = normalizePathForComparison(candidate, resolveBase);
533
618
  if (!normalized) continue;
534
619
 
535
620
  if (
536
- isPathOutsideWorkingDirectory(candidate, cwd) &&
621
+ normalizedCwd !== "" &&
622
+ !isSafeSystemPath(normalized) &&
623
+ !isPathWithinDirectory(normalized, normalizedCwd) &&
537
624
  !seen.has(normalized)
538
625
  ) {
539
626
  seen.add(normalized);
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./service";
28
28
  import { createSessionLogger } from "./session-logger";
29
29
  import { isSubagentExecutionContext } from "./subagent-context";
30
+ import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
30
31
  import { SubagentSessionRegistry } from "./subagent-registry";
31
32
  import {
32
33
  canResolveAskPermissionRequest,
@@ -129,6 +130,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
130
  };
130
131
  publishPermissionsService(permissionsService);
131
132
 
133
+ // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
134
+ // sessions register/unregister without the core calling us (ADR 0002).
135
+ const unsubSubagentLifecycle = subscribeSubagentLifecycle(
136
+ pi.events,
137
+ subagentRegistry,
138
+ );
139
+
132
140
  emitReadyEvent(pi.events);
133
141
 
134
142
  const toolRegistry = {
@@ -139,6 +147,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
139
147
  const lifecycle = new SessionLifecycleHandler(session, () => {
140
148
  rpcHandles.unsubCheck();
141
149
  rpcHandles.unsubPrompt();
150
+ unsubSubagentLifecycle();
142
151
  unpublishPermissionsService();
143
152
  });
144
153
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
3
+ * lifecycle events and keep the SubagentSessionRegistry in sync.
4
+ *
5
+ * @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
6
+ * event bus (ADR 0002): it no longer calls this package's service directly.
7
+ * We register the child on `session-created` and unregister it on `disposed`.
8
+ *
9
+ * The channel names and payload shapes are declared independently here (the two
10
+ * packages must not depend on each other under jiti) and MUST match the
11
+ * publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
12
+ *
13
+ * The `session-created` handler MUST stay synchronous: the core emits it on the
14
+ * same synchronous call stack immediately before `bindExtensions()`, and the
15
+ * event bus dispatches listeners synchronously, so a synchronous handler lands
16
+ * the registry entry before binding proceeds. Introducing an `await` before
17
+ * `registry.register(...)` would break the pre-bind ordering.
18
+ */
19
+
20
+ import type { SubagentSessionRegistry } from "./subagent-registry";
21
+
22
+ /** Emitted by the core after session creation, before `bindExtensions()`. */
23
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
24
+
25
+ /** Emitted by the core in the run's `finally` (success and error). */
26
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
27
+
28
+ /** Minimal event-bus surface this module needs (subscribe only). */
29
+ interface LifecycleEventBus {
30
+ on(channel: string, handler: (data: unknown) => void): () => void;
31
+ }
32
+
33
+ /** Fields read from the `session-created` payload (ISP). */
34
+ interface ChildSessionCreatedEvent {
35
+ sessionDir: string;
36
+ agentName: string;
37
+ parentSessionId?: string;
38
+ }
39
+
40
+ /** Fields read from the `disposed` payload (ISP). */
41
+ interface ChildDisposedEvent {
42
+ sessionDir: string;
43
+ }
44
+
45
+ /**
46
+ * Subscribe to the subagent child lifecycle.
47
+ *
48
+ * @returns an unsubscribe that detaches both handlers (call during
49
+ * `session_shutdown`).
50
+ */
51
+ export function subscribeSubagentLifecycle(
52
+ events: LifecycleEventBus,
53
+ registry: SubagentSessionRegistry,
54
+ ): () => void {
55
+ const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
56
+ const event = data as ChildSessionCreatedEvent;
57
+ registry.register(event.sessionDir, {
58
+ agentName: event.agentName,
59
+ parentSessionId: event.parentSessionId,
60
+ });
61
+ });
62
+
63
+ const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
+ const event = data as ChildDisposedEvent;
65
+ registry.unregister(event.sessionDir);
66
+ });
67
+
68
+ return () => {
69
+ unsubCreated();
70
+ unsubDisposed();
71
+ };
72
+ }
@@ -822,6 +822,82 @@ describe("extractExternalPathsFromBashCommand", () => {
822
822
  expect(result).toContain("/etc/hosts");
823
823
  });
824
824
  });
825
+
826
+ describe("leading cd prefix", () => {
827
+ test("regression: cd to subdir with relative path traversing back into cwd is not flagged", async () => {
828
+ // Real-world command that triggered a false-positive external-directory
829
+ // prompt. The relative path .pi/../../../.pi/skills/... resolves inside
830
+ // cwd when resolved from the cd target, but outside cwd when resolved
831
+ // from cwd itself.
832
+ const result = await extractExternalPathsFromBashCommand(
833
+ 'cd /projects/my-app/packages/sub && grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
834
+ cwd,
835
+ );
836
+ expect(result).toHaveLength(0);
837
+ });
838
+
839
+ test("cd to subdir: still flags genuinely external paths after cd", async () => {
840
+ const result = await extractExternalPathsFromBashCommand(
841
+ "cd /projects/my-app/packages/sub && cat /etc/hosts",
842
+ cwd,
843
+ );
844
+ expect(result).toContain("/etc/hosts");
845
+ });
846
+
847
+ test("cd to subdir: relative path that stays inside cwd is not flagged", async () => {
848
+ const result = await extractExternalPathsFromBashCommand(
849
+ "cd /projects/my-app/src && cat ../README.md",
850
+ cwd,
851
+ );
852
+ expect(result).toHaveLength(0);
853
+ });
854
+
855
+ test("cd to external dir: paths after cd are still checked against cwd", async () => {
856
+ // When cd target is outside cwd, we fall back to cwd as the resolve base.
857
+ // The cd target itself should be flagged, and paths after cd are resolved
858
+ // against cwd.
859
+ const result = await extractExternalPathsFromBashCommand(
860
+ "cd /tmp && cat ../etc/hosts",
861
+ cwd,
862
+ );
863
+ expect(result.length).toBeGreaterThan(0);
864
+ });
865
+
866
+ test("cd with relative target: resolves inside cwd", async () => {
867
+ const result = await extractExternalPathsFromBashCommand(
868
+ 'cd packages/sub && grep -n "x" .pi/../../../.pi/skills/pkg/SKILL.md',
869
+ cwd,
870
+ );
871
+ expect(result).toHaveLength(0);
872
+ });
873
+
874
+ test("no cd prefix: ../ path that escapes cwd is flagged", async () => {
875
+ // Without the cd prefix, the path resolves against cwd and escapes.
876
+ const result = await extractExternalPathsFromBashCommand(
877
+ 'grep -n "pattern" .pi/../../../.pi/skills/pkg/SKILL.md',
878
+ cwd,
879
+ );
880
+ expect(result.length).toBeGreaterThan(0);
881
+ });
882
+
883
+ test("cd is not first command: cd is ignored", async () => {
884
+ // cd after another command should not affect path resolution.
885
+ const result = await extractExternalPathsFromBashCommand(
886
+ "echo hello && cd /projects/my-app/src && cat ../../outside.txt",
887
+ cwd,
888
+ );
889
+ // ../../outside.txt resolves against cwd, not the cd target
890
+ expect(result.length).toBeGreaterThan(0);
891
+ });
892
+
893
+ test("cd with semicolon separator", async () => {
894
+ const result = await extractExternalPathsFromBashCommand(
895
+ "cd /projects/my-app/src ; cat ../README.md",
896
+ cwd,
897
+ );
898
+ expect(result).toHaveLength(0);
899
+ });
900
+ });
825
901
  });
826
902
 
827
903
  describe("formatBashExternalDirectoryAskPrompt", () => {
@@ -0,0 +1,113 @@
1
+ import { createEventBus } from "@earendil-works/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ SUBAGENT_CHILD_DISPOSED,
5
+ SUBAGENT_CHILD_SESSION_CREATED,
6
+ subscribeSubagentLifecycle,
7
+ } from "#src/subagent-lifecycle-events";
8
+ import { SubagentSessionRegistry } from "#src/subagent-registry";
9
+
10
+ describe("subscribeSubagentLifecycle", () => {
11
+ let registry: SubagentSessionRegistry;
12
+
13
+ beforeEach(() => {
14
+ registry = new SubagentSessionRegistry();
15
+ });
16
+
17
+ it("registers a child session on session-created", () => {
18
+ const bus = createEventBus();
19
+ subscribeSubagentLifecycle(bus, registry);
20
+
21
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
22
+ sessionDir: "/sessions/child-abc",
23
+ agentName: "Explore",
24
+ parentSessionId: "parent-42",
25
+ });
26
+
27
+ expect(registry.get("/sessions/child-abc")).toEqual({
28
+ agentName: "Explore",
29
+ parentSessionId: "parent-42",
30
+ });
31
+ });
32
+
33
+ it("populates the registry synchronously — before emit() returns", () => {
34
+ // Guards the pre-bindExtensions ordering: the core emits session-created
35
+ // on the same synchronous call stack right before bindExtensions(), so the
36
+ // handler must complete before emit() returns. A real EventEmitter-backed
37
+ // bus dispatches synchronously; this fails loudly if the handler ever
38
+ // becomes async (awaiting before registry.register).
39
+ const bus = createEventBus();
40
+ subscribeSubagentLifecycle(bus, registry);
41
+
42
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
43
+ sessionDir: "/sessions/child-sync",
44
+ agentName: "Explore",
45
+ });
46
+
47
+ // No await between emit and this assertion.
48
+ expect(registry.has("/sessions/child-sync")).toBe(true);
49
+ });
50
+
51
+ it("omits parentSessionId when the event does not carry one", () => {
52
+ const bus = createEventBus();
53
+ subscribeSubagentLifecycle(bus, registry);
54
+
55
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
56
+ sessionDir: "/sessions/child-xyz",
57
+ agentName: "general-purpose",
58
+ });
59
+
60
+ expect(registry.get("/sessions/child-xyz")).toEqual({
61
+ agentName: "general-purpose",
62
+ parentSessionId: undefined,
63
+ });
64
+ });
65
+
66
+ it("unregisters a child session on disposed", () => {
67
+ const bus = createEventBus();
68
+ subscribeSubagentLifecycle(bus, registry);
69
+ registry.register("/sessions/child-abc", { agentName: "Explore" });
70
+
71
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
72
+
73
+ expect(registry.has("/sessions/child-abc")).toBe(false);
74
+ });
75
+
76
+ it("detaches both handlers when the returned unsubscribe is called", () => {
77
+ const bus = createEventBus();
78
+ const unsubscribe = subscribeSubagentLifecycle(bus, registry);
79
+
80
+ unsubscribe();
81
+
82
+ bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
83
+ sessionDir: "/sessions/child-abc",
84
+ agentName: "Explore",
85
+ });
86
+ bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
87
+
88
+ expect(registry.has("/sessions/child-abc")).toBe(false);
89
+ });
90
+
91
+ it("subscribes to a fake bus on the exact channel names", () => {
92
+ const handlers = new Map<string, (data: unknown) => void>();
93
+ const bus = {
94
+ on: vi.fn((channel: string, handler: (data: unknown) => void) => {
95
+ handlers.set(channel, handler);
96
+ return () => handlers.delete(channel);
97
+ }),
98
+ };
99
+
100
+ subscribeSubagentLifecycle(bus, registry);
101
+
102
+ expect(bus.on).toHaveBeenCalledTimes(2);
103
+ expect(handlers.has("subagents:child:session-created")).toBe(true);
104
+ expect(handlers.has("subagents:child:disposed")).toBe(true);
105
+ });
106
+
107
+ it("exposes the canonical channel-name strings", () => {
108
+ expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
109
+ "subagents:child:session-created",
110
+ );
111
+ expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
112
+ });
113
+ });