@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
|
@@ -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 () => {
|