@gotgenes/pi-permission-system 16.0.0 → 16.0.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,13 @@ 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
+ ## [16.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.0.0...pi-permission-system-v16.0.1) (2026-06-21)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** fold cd across redirect-then-pipe in external-directory projection ([293c0b7](https://github.com/gotgenes/pi-packages/commit/293c0b797a17e3c713520419565e632d45632d11)), closes [#454](https://github.com/gotgenes/pi-packages/issues/454)
14
+
8
15
  ## [16.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v15.1.0...pi-permission-system-v16.0.0) (2026-06-21)
9
16
 
10
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "16.0.0",
3
+ "version": "16.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -205,7 +205,10 @@ export class BashProgram {
205
205
  * where it appears, projected by folding a sequence of current-shell `cd`
206
206
  * commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
207
207
  * pipeline or a backgrounded command runs in a subshell and does not update
208
- * the running directory.
208
+ * the running directory; a leading current-shell `cd` before a
209
+ * redirect-then-pipe (`cd a && pnpm x 2>&1 | tail`) folds because bash `|`
210
+ * binds tighter than `&&`/`||`/`;`, even though tree-sitter-bash groups the
211
+ * whole redirected list as the pipeline's first stage (#454).
209
212
  *
210
213
  * The outside-`cwd` decision and the dedup identity use the canonical
211
214
  * (symlink-resolved) form, but the returned value is the lexical form so
@@ -854,6 +857,15 @@ function walkForCandidates(
854
857
  case "command":
855
858
  tagTokens(collectCommandTokens(node), base, out);
856
859
  return foldCd(node, base);
860
+ case "pipeline":
861
+ // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
862
+ // first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
863
+ // `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside a
864
+ // node the `default` case treats as non-folding. Recover bash operator
865
+ // precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first
866
+ // stage's leading current-shell commands while keeping its terminal
867
+ // command and every downstream stage as non-folding subshells (#454).
868
+ return walkPipeline(node, base, out);
857
869
  case "subshell":
858
870
  // A subshell runs in a child shell: its interior `cd`s fold within the
859
871
  // subshell but reset on exit, so the folded base is discarded.
@@ -895,6 +907,109 @@ function walkCurrentShellSequence(
895
907
  return current;
896
908
  }
897
909
 
910
+ /**
911
+ * Walk a `pipeline` node, returning the effective base in force after it.
912
+ *
913
+ * Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd`
914
+ * inside any stage must not leak — the base normally passes through unchanged.
915
+ * The exception is the first stage: tree-sitter-bash wraps a redirect-bearing
916
+ * current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage, and
917
+ * bash precedence makes the list's leading commands current-shell, so they fold
918
+ * and the folded base persists past the pipeline to following siblings.
919
+ *
920
+ * The terminal command of the first stage is the real pipe stage (a subshell)
921
+ * and must not fold; every stage after a `|` is a downstream subshell stage and
922
+ * collects tokens against the folded base without folding (#454).
923
+ */
924
+ function walkPipeline(
925
+ node: TSNode,
926
+ base: EffectiveBase,
927
+ out: PathCandidate[],
928
+ ): EffectiveBase {
929
+ let current = base;
930
+ let first = true;
931
+ for (let i = 0; i < node.childCount; i++) {
932
+ const child = node.child(i);
933
+ if (!child?.isNamed) continue;
934
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
935
+ if (first) {
936
+ current = foldPipelineFirstStage(child, current, out);
937
+ first = false;
938
+ continue;
939
+ }
940
+ // Downstream stage (after a `|`): subshell — collect against the folded
941
+ // base, do not fold.
942
+ tagTokens(collectPathCandidateTokens(child), current, out);
943
+ }
944
+ return current;
945
+ }
946
+
947
+ /**
948
+ * Collect the first pipe stage's candidates, folding its leading current-shell
949
+ * `cd` commands when tree-sitter wrapped a `list` or `redirected_statement`
950
+ * around them. The terminal command of that container is the real pipe stage (a
951
+ * subshell) and is collected without folding. A bare `command` first stage (a
952
+ * true pipeline first stage such as `cd nested | cat ../b`) is a subshell: it
953
+ * collects against the input base and does not fold.
954
+ */
955
+ function foldPipelineFirstStage(
956
+ node: TSNode,
957
+ base: EffectiveBase,
958
+ out: PathCandidate[],
959
+ ): EffectiveBase {
960
+ if (node.type === "list") return foldListExceptTerminal(node, base, out);
961
+ if (node.type === "redirected_statement") {
962
+ let current = base;
963
+ for (let i = 0; i < node.childCount; i++) {
964
+ const child = node.child(i);
965
+ if (!child?.isNamed) continue;
966
+ if (child.type === "file_redirect") {
967
+ // Redirect destinations are part of the piped stage; collect them
968
+ // against the folded base without folding.
969
+ tagTokens(collectRedirectTokens(child), current, out);
970
+ continue;
971
+ }
972
+ // The inner statement is the `list`/`command` being redirected; fold its
973
+ // leading current-shell commands via the terminal-excluding walk.
974
+ current = foldPipelineFirstStage(child, current, out);
975
+ }
976
+ return current;
977
+ }
978
+ // Bare `command` or any other shape: a true subshell first stage.
979
+ tagTokens(collectPathCandidateTokens(node), base, out);
980
+ return base;
981
+ }
982
+
983
+ /**
984
+ * Fold every named, non-skip child of a `list` except the last, threading the
985
+ * effective base left-to-right through the leading current-shell commands; the
986
+ * terminal child is the real pipe stage and is collected without folding.
987
+ */
988
+ function foldListExceptTerminal(
989
+ node: TSNode,
990
+ base: EffectiveBase,
991
+ out: PathCandidate[],
992
+ ): EffectiveBase {
993
+ const namedChildren: TSNode[] = [];
994
+ for (let i = 0; i < node.childCount; i++) {
995
+ const child = node.child(i);
996
+ if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) {
997
+ namedChildren.push(child);
998
+ }
999
+ }
1000
+ let current = base;
1001
+ for (let i = 0; i < namedChildren.length; i++) {
1002
+ const child = namedChildren[i];
1003
+ if (i < namedChildren.length - 1) {
1004
+ current = walkForCandidates(child, current, out);
1005
+ } else {
1006
+ // Terminal child = the real pipe stage; collect without folding.
1007
+ tagTokens(collectPathCandidateTokens(child), current, out);
1008
+ }
1009
+ }
1010
+ return current;
1011
+ }
1012
+
898
1013
  /**
899
1014
  * True when the statement at `index` is immediately followed by the background
900
1015
  * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell separators.
@@ -521,3 +521,83 @@ describe("multi-instance global service interplay", () => {
521
521
  rmSync(childCwd, { recursive: true, force: true });
522
522
  });
523
523
  });
524
+
525
+ describe("session approvals do not leak across same-cwd session switches", () => {
526
+ // Pi caches the extension *import* (the jiti module, factory function) for
527
+ // same-cwd `/new` / `/resume` / `/fork` / `/import` switches
528
+ // (earendil-works/pi#5905). The factory is still re-invoked per switch, and
529
+ // `session_shutdown` still fires — so a session-scoped "allow for this
530
+ // session" grant must not survive into the next session.
531
+ //
532
+ // Two factory invocations against the same cwd model the cached-import
533
+ // switch: invocation #1 records an approval and shuts down; invocation #2 is
534
+ // the re-invoked cached factory. The new session must start with an empty
535
+ // SessionRules. Two independent mechanisms keep it empty, and the grant only
536
+ // leaks if *both* break together: `session_shutdown` clears the first
537
+ // instance's rules, and the re-invoked factory builds a fresh SessionRules
538
+ // (no module-scoped state bridges the switch — the per-session reset the
539
+ // fresh-jiti load used to provide is gone once the import is cached).
540
+
541
+ /** A UI ctx that approves the gate's "for this session" option (options[1]). */
542
+ function makeSessionApprovingCtx(cwd: string, sessionId: string): unknown {
543
+ return {
544
+ cwd,
545
+ hasUI: true,
546
+ sessionManager: {
547
+ getEntries: (): unknown[] => [],
548
+ getSessionId: (): string => sessionId,
549
+ getSessionDir: (): string => cwd,
550
+ },
551
+ ui: {
552
+ notify: (): void => {},
553
+ setStatus: (): void => {},
554
+ select: async (
555
+ _title: string,
556
+ options: string[],
557
+ ): Promise<string | undefined> => options[1],
558
+ input: async (): Promise<string | undefined> => undefined,
559
+ },
560
+ };
561
+ }
562
+
563
+ it("starts the next same-cwd session with an empty session ruleset", async () => {
564
+ writeGlobalConfig({
565
+ permission: { "*": "allow", demo: "ask" },
566
+ });
567
+
568
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-switch-cwd-"));
569
+
570
+ // ── Session #1: approve `demo` for the session, then shut down ──────────
571
+ const firstPi = makeFakePi({ toolNames: ["demo"] });
572
+ piPermissionSystemExtension(firstPi as unknown as ExtensionAPI);
573
+
574
+ const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1");
575
+ await fireSessionStart(firstPi, firstCtx);
576
+
577
+ // The gate prompts and the mock selects options[1], recording a
578
+ // session-scoped approval the service can read back.
579
+ await firstPi.fire(
580
+ "tool_call",
581
+ { toolName: "demo", toolCallId: "demo-approve", input: { foo: "bar" } },
582
+ firstCtx,
583
+ );
584
+ expect(getPermissionsService()!.checkPermission("demo").state).toBe(
585
+ "allow",
586
+ );
587
+
588
+ // The switch tears down the old session before the new one starts.
589
+ await firstPi.fire("session_shutdown");
590
+
591
+ // ── Session #2: the re-invoked cached factory, same cwd ────────────────
592
+ const secondPi = makeFakePi({ toolNames: ["demo"] });
593
+ piPermissionSystemExtension(secondPi as unknown as ExtensionAPI);
594
+
595
+ await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2"));
596
+
597
+ // The previous session's approval must not be visible: `demo` is back to
598
+ // its configured `ask`, not the carried-over `allow`.
599
+ expect(getPermissionsService()!.checkPermission("demo").state).toBe("ask");
600
+
601
+ rmSync(cwd, { recursive: true, force: true });
602
+ });
603
+ });
@@ -186,6 +186,50 @@ describe("BashProgram", () => {
186
186
  );
187
187
  expect(program.externalPaths(cwd)).toHaveLength(0);
188
188
  });
189
+
190
+ it("folds a leading current-shell cd across a redirect-then-pipe", async () => {
191
+ // tree-sitter-bash groups `cd a && pnpm x 2>&1 | tail` as
192
+ // `(cd a && pnpm x 2>&1) | tail`, burying the current-shell `cd a`
193
+ // inside a `pipeline` node. Bash precedence (`|` binds tighter than
194
+ // `&&`) makes `cd a` current-shell, so the fold must persist past the
195
+ // pipeline: ../b resolves against cwd/a (inside), not cwd (#454).
196
+ const program = await BashProgram.parse(
197
+ "cd a && pnpm x 2>&1 | tail ; cat ../b",
198
+ );
199
+ expect(program.externalPaths(cwd)).toHaveLength(0);
200
+ });
201
+
202
+ it("persists the fold past a redirect-then-pipe to a later cd", async () => {
203
+ // The issue reproduction: the fold from `cd a/b` survives the
204
+ // redirect-then-pipe, so the trailing `cd .. && cd ..` lands back at
205
+ // cwd instead of escaping one level above.
206
+ const program = await BashProgram.parse(
207
+ "cd a/b && pnpm x 2>&1 | tail ; cd .. && cd ..",
208
+ );
209
+ expect(program.externalPaths(cwd)).toHaveLength(0);
210
+ });
211
+
212
+ it("does not fold the terminal piped command of the first stage", async () => {
213
+ // Fail-closed: `cd b` is the terminal command of the first stage, i.e.
214
+ // the real pipe stage (a subshell), so it must NOT fold. With the
215
+ // correct base cwd/a, ../../x escapes to /projects/x. If `cd b` were
216
+ // wrongly folded, the base would be cwd/a/b and ../../x would stay
217
+ // inside — a fail-open regression this test pins.
218
+ const program = await BashProgram.parse(
219
+ "cd a && cd b 2>&1 | tail ; cat ../../x",
220
+ );
221
+ expect(program.externalPaths(cwd)).toContain("/projects/x");
222
+ });
223
+
224
+ it("resolves a downstream pipe stage against the folded base", async () => {
225
+ // The stage after the `|` runs in a subshell that inherits the folded
226
+ // cwd/a, so ../foo resolves inside cwd rather than escaping against the
227
+ // pre-cd base.
228
+ const program = await BashProgram.parse(
229
+ "cd a && pnpm x 2>&1 | cat ../foo",
230
+ );
231
+ expect(program.externalPaths(cwd)).toHaveLength(0);
232
+ });
189
233
  });
190
234
 
191
235
  it("flags an absolute in-cwd path that resolves externally via a symlink, returning the typed form", async () => {