@gotgenes/pi-permission-system 9.1.0 → 10.0.0

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,41 @@ 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
+ ## [10.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.2.0...pi-permission-system-v10.0.0) (2026-06-02)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** the permissions:ready event payload no longer includes protocolVersion. Consumers that read it must rely on package semver instead.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-manager:** broadcast permission prompts on permissions:prompt channel ([8540f3b](https://github.com/gotgenes/pi-packages/commit/8540f3b462b76a4789c4c17a75fadf254ae39feb))
18
+ * **pi-permission-system:** drop protocolVersion from permissions:ready ([6728a93](https://github.com/gotgenes/pi-packages/commit/6728a93af7edbc6953d20f448f1c3f54f9b7893f))
19
+ * **pi-permission-system:** harden prompt broadcasts ([067bafd](https://github.com/gotgenes/pi-packages/commit/067bafd80ef983fd8b9ab00914cf1cec9b6db915))
20
+ * **pi-permission-system:** make ready and decision broadcasts best-effort ([00a895f](https://github.com/gotgenes/pi-packages/commit/00a895f9377bcb7b598acbc6c95d7bc7cc83c515))
21
+ * **pi-permission-system:** preserve display fields for forwarded prompts ([9970912](https://github.com/gotgenes/pi-packages/commit/997091228736bbd4395d8bd16aeb9f4a4ae7e0b2))
22
+ * **pi-permission-system:** slim ui_prompt payload and centralize construction ([7a1ec56](https://github.com/gotgenes/pi-packages/commit/7a1ec56a827e90fe80a0f7de48e1222b5271700d))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **pi-permission-system:** drop manual CHANGELOG Unreleased section ([f14e4f5](https://github.com/gotgenes/pi-packages/commit/f14e4f5d9b5ae1d6b207a913aefdd71980a46dd6))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * **pi-permission-system:** document the lean ui_prompt contract ([0b3c11c](https://github.com/gotgenes/pi-packages/commit/0b3c11c57a7b718d2f73a184802f8c5dcb95fbe7))
33
+
34
+ ## [9.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.1.0...pi-permission-system-v9.2.0) (2026-06-02)
35
+
36
+
37
+ ### Features
38
+
39
+ * flag relative paths conservatively after a non-literal cd ([6e631a0](https://github.com/gotgenes/pi-packages/commit/6e631a0c62633e0be3a498752a3bf3614d357d65)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
40
+ * fold sequential current-shell cd into the bash effective directory ([7fd8e95](https://github.com/gotgenes/pi-packages/commit/7fd8e9525196ffa558a2b59ea9f4cf66943f9010)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
41
+ * scope cd inside subshells and persist it across brace groups ([37b948c](https://github.com/gotgenes/pi-packages/commit/37b948c7e9fd5d9ddac3aa8c6b456039132f7c4e)), closes [#307](https://github.com/gotgenes/pi-packages/issues/307)
42
+
8
43
  ## [9.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.1...pi-permission-system-v9.1.0) (2026-06-02)
9
44
 
10
45
 
package/README.md CHANGED
@@ -20,6 +20,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
20
20
  - **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
21
21
  - **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
22
22
  - **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
23
+ - **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
23
24
  - **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
24
25
 
25
26
  ## Install
@@ -89,16 +90,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
89
90
 
90
91
  ## Documentation
91
92
 
92
- | Document | Contents |
93
- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
94
- | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
95
- | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
96
- | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, decision broadcasts |
97
- | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
98
- | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
99
- | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
100
- | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
101
- | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
93
+ | Document | Contents |
94
+ | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
95
+ | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
96
+ | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
97
+ | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
98
+ | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
99
+ | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
100
+ | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
101
+ | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
102
+ | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
102
103
 
103
104
  ## Development
104
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.1.0",
3
+ "version": "10.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,6 +10,7 @@ import {
10
10
  } from "node:fs";
11
11
 
12
12
  import { isPermissionDecisionState } from "#src/permission-dialog";
13
+ import type { PermissionUiPromptSource } from "#src/permission-events";
13
14
  import {
14
15
  createPermissionForwardingLocation,
15
16
  type ForwardedPermissionRequest,
@@ -17,6 +18,29 @@ import {
17
18
  type PermissionForwardingLocation,
18
19
  } from "#src/permission-forwarding";
19
20
 
21
+ /** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
22
+ const UI_PROMPT_SOURCES = [
23
+ "tool_call",
24
+ "skill_input",
25
+ "skill_read",
26
+ "rpc_prompt",
27
+ ] as const satisfies readonly PermissionUiPromptSource[];
28
+
29
+ /** Narrow an unknown value to a valid prompt source, or `undefined`. */
30
+ function asUiPromptSource(
31
+ value: unknown,
32
+ ): PermissionUiPromptSource | undefined {
33
+ return UI_PROMPT_SOURCES.find((source) => source === value);
34
+ }
35
+
36
+ /** Narrow an unknown value to a nullable display string, or `undefined`. */
37
+ function asNullableDisplayString(value: unknown): string | null | undefined {
38
+ if (value === null || typeof value === "string") {
39
+ return value;
40
+ }
41
+ return undefined;
42
+ }
43
+
20
44
  type LogFn = (event: string, details: Record<string, unknown>) => void;
21
45
 
22
46
  export interface ForwardedPermissionLogger {
@@ -285,6 +309,11 @@ export function readForwardedPermissionRequest(
285
309
  targetSessionId: parsed.targetSessionId,
286
310
  requesterAgentName: parsed.requesterAgentName,
287
311
  message: parsed.message,
312
+ // Tolerant read: display fields are optional and may be absent (older
313
+ // child) or malformed; reconstruct only the well-formed ones.
314
+ source: asUiPromptSource(parsed.source),
315
+ surface: asNullableDisplayString(parsed.surface),
316
+ value: asNullableDisplayString(parsed.value),
288
317
  };
289
318
  } catch (error) {
290
319
  logPermissionForwardingWarning(
@@ -11,15 +11,21 @@ import type {
11
11
  PermissionPromptDecision,
12
12
  RequestPermissionOptions,
13
13
  } from "#src/permission-dialog";
14
+ import {
15
+ emitUiPromptEvent,
16
+ type PermissionEventBus,
17
+ } from "#src/permission-events";
14
18
  import {
15
19
  type ForwardedPermissionRequest,
16
20
  type ForwardedPermissionResponse,
21
+ type ForwardedPromptDisplay,
17
22
  isForwardedPermissionRequestForSession,
18
23
  PERMISSION_FORWARDING_POLL_INTERVAL_MS,
19
24
  PERMISSION_FORWARDING_TIMEOUT_MS,
20
25
  resolvePermissionForwardingTargetSessionId,
21
26
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
22
27
  } from "#src/permission-forwarding";
28
+ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
23
29
  import { isSubagentExecutionContext } from "#src/subagent-context";
24
30
  import type { SubagentSessionRegistry } from "#src/subagent-registry";
25
31
 
@@ -43,6 +49,8 @@ export interface PermissionForwardingDeps {
43
49
  subagentSessionsDir: string;
44
50
  /** In-process subagent session registry for detection and forwarding target resolution. */
45
51
  registry?: SubagentSessionRegistry;
52
+ /** Event bus used for UI prompt broadcasts. */
53
+ events?: PermissionEventBus;
46
54
  logger: ForwardedPermissionLogger;
47
55
  writeReviewLog: (event: string, details: Record<string, unknown>) => void;
48
56
  requestPermissionDecisionFromUi: (
@@ -103,6 +111,7 @@ export async function waitForForwardedPermissionApproval(
103
111
  ctx: ExtensionContext,
104
112
  message: string,
105
113
  deps: PermissionForwardingDeps,
114
+ forwarded?: ForwardedPromptDisplay,
106
115
  ): Promise<PermissionPromptDecision> {
107
116
  const requesterSessionId = getSessionId(ctx);
108
117
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
@@ -155,6 +164,13 @@ export async function waitForForwardedPermissionApproval(
155
164
  targetSessionId,
156
165
  requesterAgentName,
157
166
  message,
167
+ ...(forwarded
168
+ ? {
169
+ source: forwarded.source,
170
+ surface: forwarded.surface,
171
+ value: forwarded.value,
172
+ }
173
+ : {}),
158
174
  };
159
175
 
160
176
  const requestPath = join(location.requestsDir, `${requestId}.json`);
@@ -296,10 +312,25 @@ export async function processForwardedPermissionRequests(
296
312
  forwardedPermissionLogDetails,
297
313
  );
298
314
  try {
315
+ const forwardedMessage = formatForwardedPermissionPrompt(request);
316
+ if (deps.events) {
317
+ emitUiPromptEvent(
318
+ deps.events,
319
+ buildForwardedUiPrompt({
320
+ requestId: request.id,
321
+ message: forwardedMessage,
322
+ requesterAgentName: request.requesterAgentName || null,
323
+ requesterSessionId: request.requesterSessionId || null,
324
+ source: request.source ?? null,
325
+ surface: request.surface ?? null,
326
+ value: request.value ?? null,
327
+ }),
328
+ );
329
+ }
299
330
  decision = await deps.requestPermissionDecisionFromUi(
300
331
  ctx.ui,
301
332
  "Permission Required (Subagent)",
302
- formatForwardedPermissionPrompt(request),
333
+ forwardedMessage,
303
334
  );
304
335
  } catch (error) {
305
336
  logPermissionForwardingError(
@@ -359,6 +390,7 @@ export async function confirmPermission(
359
390
  message: string,
360
391
  deps: PermissionForwardingDeps,
361
392
  options?: RequestPermissionOptions,
393
+ forwarded?: ForwardedPromptDisplay,
362
394
  ): Promise<PermissionPromptDecision> {
363
395
  if (ctx.hasUI) {
364
396
  return deps.requestPermissionDecisionFromUi(
@@ -375,5 +407,5 @@ export async function confirmPermission(
375
407
  return { approved: false, state: "denied" };
376
408
  }
377
409
 
378
- return waitForForwardedPermissionApproval(ctx, message, deps);
410
+ return waitForForwardedPermissionApproval(ctx, message, deps, forwarded);
379
411
  }
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { basename, resolve } from "node:path";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
3
 
4
4
  import {
5
5
  classifyTokenAsPathCandidate,
@@ -75,6 +75,29 @@ export interface BashCommand {
75
75
  readonly context?: BashCommandContext;
76
76
  }
77
77
 
78
+ /**
79
+ * The working directory in force where a path candidate appears.
80
+ *
81
+ * A `known` base carries an `offset` to be joined with `cwd` at resolution time
82
+ * (the parse-time walk never sees `cwd`): a relative-or-absolute path string
83
+ * built by folding the literal targets of current-shell `cd` commands (`""` =
84
+ * `cwd`); an absolute offset (from `cd /abs`) ignores `cwd` at resolution time.
85
+ * An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`,
86
+ * `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable.
87
+ */
88
+ type EffectiveBase =
89
+ | { readonly kind: "known"; readonly offset: string }
90
+ | { readonly kind: "unknown" };
91
+
92
+ /**
93
+ * A path-candidate token paired with the effective working directory projected
94
+ * onto the point in the command stream where it appears.
95
+ */
96
+ interface PathCandidate {
97
+ readonly token: string;
98
+ readonly base: EffectiveBase;
99
+ }
100
+
78
101
  /**
79
102
  * A bash command parsed once into a reusable representation.
80
103
  *
@@ -87,29 +110,29 @@ export interface BashCommand {
87
110
  */
88
111
  export class BashProgram {
89
112
  private constructor(
90
- private readonly rawTokens: readonly string[],
91
- private readonly leadingCdTarget: string | undefined,
113
+ private readonly rawCandidates: readonly PathCandidate[],
92
114
  private readonly commandUnits: readonly BashCommand[],
93
115
  ) {}
94
116
 
95
117
  /**
96
118
  * Parse a bash command into a `BashProgram`.
97
119
  *
98
- * Uses tree-sitter-bash to build the full AST, walks command-argument and
99
- * redirect-destination nodes once into raw candidate tokens, and records the
100
- * leading `cd` target. Heredoc bodies, comments, and other non-argument
101
- * content are skipped. An unparseable command yields an empty program.
120
+ * Uses tree-sitter-bash to build the full AST and walks command-argument and
121
+ * redirect-destination nodes once into raw candidate tokens, each tagged with
122
+ * the effective working directory projected onto its position by folding
123
+ * current-shell `cd` commands. Heredoc bodies, comments, and other
124
+ * non-argument content are skipped. An unparseable command yields an empty
125
+ * program.
102
126
  */
103
127
  static async parse(command: string): Promise<BashProgram> {
104
128
  const parser = await getParser();
105
129
  const tree = parser.parse(command);
106
- if (!tree) return new BashProgram([], undefined, []);
130
+ if (!tree) return new BashProgram([], []);
107
131
 
108
132
  try {
109
- const leadingCdTarget = extractLeadingCdTarget(tree.rootNode);
110
- const rawTokens = collectPathCandidateTokens(tree.rootNode);
133
+ const rawCandidates = collectPathCandidates(tree.rootNode);
111
134
  const commandUnits = collectCommands(tree.rootNode);
112
- return new BashProgram(rawTokens, leadingCdTarget, commandUnits);
135
+ return new BashProgram(rawCandidates, commandUnits);
113
136
  } finally {
114
137
  tree.delete();
115
138
  }
@@ -125,7 +148,7 @@ export class BashProgram {
125
148
  pathTokens(): string[] {
126
149
  const seen = new Set<string>();
127
150
  const result: string[] = [];
128
- for (const token of this.rawTokens) {
151
+ for (const { token } of this.rawCandidates) {
129
152
  const candidate = classifyTokenAsRuleCandidate(token);
130
153
  if (!candidate) continue;
131
154
  if (!seen.has(candidate)) {
@@ -156,21 +179,42 @@ export class BashProgram {
156
179
  /**
157
180
  * Deduplicated paths that resolve outside `cwd`.
158
181
  *
159
- * When the command begins with `cd <dir> && …`, relative candidate paths are
160
- * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
161
- * mirroring how the shell would resolve them.
182
+ * Each candidate is resolved against the effective working directory in force
183
+ * where it appears, projected by folding a sequence of current-shell `cd`
184
+ * commands (joined by `&&`, `||`, `;`, or a newline). A `cd` inside a
185
+ * pipeline or a backgrounded command runs in a subshell and does not update
186
+ * the running directory.
162
187
  */
163
188
  externalPaths(cwd: string): string[] {
164
- const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
165
189
  const normalizedCwd = normalizePathForComparison(cwd, cwd);
166
190
 
167
191
  const seen = new Set<string>();
168
192
  const externalPaths: string[] = [];
169
193
 
170
- for (const token of this.rawTokens) {
194
+ for (const { token, base } of this.rawCandidates) {
171
195
  const candidate = classifyTokenAsPathCandidate(token);
172
196
  if (!candidate) continue;
173
197
 
198
+ // Unknown effective directory: a relative candidate could resolve
199
+ // anywhere, so flag it conservatively (resolving against `cwd` only for a
200
+ // display path). Absolute / `~` candidates are base-independent and
201
+ // resolve normally below.
202
+ if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
203
+ const normalized = normalizePathForComparison(candidate, cwd);
204
+ if (
205
+ normalized &&
206
+ normalizedCwd !== "" &&
207
+ !isSafeSystemPath(normalized) &&
208
+ !seen.has(normalized)
209
+ ) {
210
+ seen.add(normalized);
211
+ externalPaths.push(normalized);
212
+ }
213
+ continue;
214
+ }
215
+
216
+ const resolveBase =
217
+ base.kind === "known" ? resolve(cwd, base.offset) : cwd;
174
218
  const normalized = normalizePathForComparison(candidate, resolveBase);
175
219
  if (!normalized) continue;
176
220
 
@@ -733,75 +777,199 @@ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
733
777
  }
734
778
  }
735
779
 
736
- // ── Leading cd detection ───────────────────────────────────────────────────
780
+ // ── Effective working directory projection ─────────────────────────────────
781
+
782
+ /** The working directory in force at the start of a program (`cwd`). */
783
+ const CWD_BASE: EffectiveBase = { kind: "known", offset: "" };
784
+
785
+ /** The effective directory after a non-literal or unresolvable `cd`. */
786
+ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
737
787
 
738
788
  /**
739
- * Walk down from the root to find the first `command` node in the program.
789
+ * Walk the AST once, collecting every path-candidate token tagged with the
790
+ * effective working directory projected onto its position.
740
791
  *
741
- * Only descends into `program` and `list` nodes subshells, pipelines, and
742
- * other compound statements are ignored because a `cd` inside them does not
743
- * affect the outer shell's working directory.
792
+ * The effective directory is stateful: it starts at `cwd` and each current-shell
793
+ * `cd <literal>` (joined by `&&`, `||`, `;`, or a newline) folds into it for
794
+ * subsequent commands. A `cd` inside a pipeline or a backgrounded command runs
795
+ * in a subshell and does not update the running directory; subshell and
796
+ * brace-group interiors inherit the enclosing base without folding their own
797
+ * `cd`s (a conservative first tier).
744
798
  */
745
- function findFirstCommand(node: TSNode): TSNode | null {
746
- if (node.type === "command") return node;
747
- if (node.type === "program" || node.type === "list") {
748
- const firstChild = node.child(0);
749
- if (firstChild) return findFirstCommand(firstChild);
799
+ function collectPathCandidates(rootNode: TSNode): PathCandidate[] {
800
+ const out: PathCandidate[] = [];
801
+ walkForCandidates(rootNode, CWD_BASE, out);
802
+ return out;
803
+ }
804
+
805
+ /**
806
+ * Collect a single node's candidates tagged with `base`, returning the
807
+ * effective base in force *after* the node (the input base unless the node is a
808
+ * current-shell `cd <literal>` that folds the running directory).
809
+ */
810
+ function walkForCandidates(
811
+ node: TSNode,
812
+ base: EffectiveBase,
813
+ out: PathCandidate[],
814
+ ): EffectiveBase {
815
+ switch (node.type) {
816
+ case "program":
817
+ case "list":
818
+ case "redirected_statement":
819
+ return walkCurrentShellSequence(node, base, out);
820
+ case "command":
821
+ tagTokens(collectCommandTokens(node), base, out);
822
+ return foldCd(node, base);
823
+ case "subshell":
824
+ // A subshell runs in a child shell: its interior `cd`s fold within the
825
+ // subshell but reset on exit, so the folded base is discarded.
826
+ walkCurrentShellSequence(node, base, out);
827
+ return base;
828
+ case "compound_statement":
829
+ // A `{ … }` brace group runs in the current shell, so its `cd`s persist
830
+ // to following commands — thread and return the folded base.
831
+ return walkCurrentShellSequence(node, base, out);
832
+ default:
833
+ // Pipelines, control-flow bodies, redirect targets, and command/process
834
+ // substitution interiors: collect every candidate in the subtree tagged
835
+ // with the enclosing base and do not fold their internal `cd`s. (Folding
836
+ // inside substitutions is deferred — conservative, never under-flags.)
837
+ tagTokens(collectPathCandidateTokens(node), base, out);
838
+ return base;
750
839
  }
751
- return null;
752
840
  }
753
841
 
754
842
  /**
755
- * Extract the target directory of a leading `cd` command from the parsed AST.
756
- *
757
- * When a bash command begins with `cd <dir> && …`, the shell resolves
758
- * subsequent relative paths against `<dir>`, not the original working
759
- * directory. The external-directory guard must do the same, otherwise a
760
- * path that the shell keeps inside the working directory can appear to
761
- * escape it and trigger a spurious permission prompt.
762
- *
763
- * Returns `undefined` when the first command is not `cd`, or when the
764
- * target cannot be meaningfully resolved (`cd -`, bare `cd`, or `cd ~…`).
843
+ * Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
844
+ * thread the effective base left-to-right through the children so a `cd` updates
845
+ * the base for following siblings. A statement immediately followed by the
846
+ * background operator (`&`) runs in a subshell, so its folded base is discarded.
847
+ */
848
+ function walkCurrentShellSequence(
849
+ seqNode: TSNode,
850
+ base: EffectiveBase,
851
+ out: PathCandidate[],
852
+ ): EffectiveBase {
853
+ let current = base;
854
+ for (let i = 0; i < seqNode.childCount; i++) {
855
+ const child = seqNode.child(i);
856
+ if (!child?.isNamed) continue;
857
+ if (SKIP_SUBTREE_TYPES.has(child.type)) continue;
858
+ const after = walkForCandidates(child, current, out);
859
+ current = isBackgrounded(seqNode, i) ? current : after;
860
+ }
861
+ return current;
862
+ }
863
+
864
+ /**
865
+ * True when the statement at `index` is immediately followed by the background
866
+ * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell separators.
867
+ */
868
+ function isBackgrounded(seqNode: TSNode, index: number): boolean {
869
+ const next = seqNode.child(index + 1);
870
+ if (!next || next.isNamed) return false;
871
+ return next.type === "&";
872
+ }
873
+
874
+ function tagTokens(
875
+ tokens: readonly string[],
876
+ base: EffectiveBase,
877
+ out: PathCandidate[],
878
+ ): void {
879
+ for (const token of tokens) out.push({ token, base });
880
+ }
881
+
882
+ /**
883
+ * True when a path candidate is relative (resolved against the effective
884
+ * directory) rather than absolute (`/…`) or home-relative (`~…`), which are
885
+ * base-independent. Used to decide which candidates an unknown base affects.
765
886
  */
766
- function extractLeadingCdTarget(rootNode: TSNode): string | undefined {
767
- const firstCmd = findFirstCommand(rootNode);
768
- if (!firstCmd) return undefined;
887
+ function isRelativeCandidate(candidate: string): boolean {
888
+ return !candidate.startsWith("/") && !candidate.startsWith("~");
889
+ }
769
890
 
770
- const cmdName = extractCommandName(firstCmd);
771
- if (cmdName !== "cd") return undefined;
891
+ /**
892
+ * Compute the effective base after a command runs. Returns `base` unchanged
893
+ * unless the command is `cd`:
894
+ *
895
+ * - `cd /abs` (absolute literal) → a fresh known base, recovering from an
896
+ * earlier unknown base.
897
+ * - `cd rel` (relative literal) → fold into a known base, or stay unknown if the
898
+ * base was already unknown.
899
+ * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) →
900
+ * unknown.
901
+ */
902
+ function foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase {
903
+ if (extractCommandName(commandNode) !== "cd") return base;
904
+ const target = cdLiteralTarget(commandNode);
905
+ if (target === null) return UNKNOWN_BASE;
906
+ if (isAbsolute(target)) return { kind: "known", offset: target };
907
+ if (base.kind === "unknown") return UNKNOWN_BASE;
908
+ return { kind: "known", offset: join(base.offset, target) };
909
+ }
772
910
 
773
- for (let i = 0; i < firstCmd.childCount; i++) {
774
- const child = firstCmd.child(i);
911
+ /**
912
+ * Resolve the literal target of a `cd` command, or `null` when the first
913
+ * argument is not a static literal (contains an expansion or command
914
+ * substitution) or cannot be resolved against the working directory (`cd -`,
915
+ * `cd ~…`, bare `cd`).
916
+ */
917
+ function cdLiteralTarget(commandNode: TSNode): string | null {
918
+ for (let i = 0; i < commandNode.childCount; i++) {
919
+ const child = commandNode.child(i);
775
920
  if (!child) continue;
776
921
  if (child.type === "command_name" || child.type === "variable_assignment")
777
922
  continue;
778
- if (!ARG_NODE_TYPES.has(child.type)) continue;
779
-
780
- const text = resolveNodeText(child);
781
- // Skip `--` (end-of-flags marker)
782
- if (text === "--") continue;
783
- // `cd -` jumps to $OLDPWD; `cd ~…` is home-relative — neither can be
784
- // resolved against the working directory.
785
- if (text === "-" || text.startsWith("~")) return undefined;
786
- return text;
923
+ if (!child.isNamed) continue;
924
+ // Skip the `--` end-of-flags marker; the next argument is the target.
925
+ if (child.type === "word" && child.text === "--") continue;
926
+ if (!ARG_NODE_TYPES.has(child.type)) return null;
927
+ return literalTextOf(child);
787
928
  }
788
- return undefined;
929
+ return null;
789
930
  }
790
931
 
791
932
  /**
792
- * Compute the effective base directory for resolving relative path candidates.
793
- *
794
- * When the leading `cd` target stays within the working directory, subsequent
795
- * relative paths should be resolved against it. An escaping target is itself
796
- * an external access (reported via its own candidate token) and must never
797
- * silence checks on subsequent paths, so the function falls back to `cwd`.
933
+ * The literal string value of an argument node, or `null` when it contains a
934
+ * variable expansion / command substitution or is a non-resolvable `cd`
935
+ * destination (`-`, `~…`).
798
936
  */
799
- function computeEffectiveResolveBase(
800
- cdTarget: string | undefined,
801
- cwd: string,
802
- ): string {
803
- if (cdTarget === undefined) return cwd;
804
- const resolved = resolve(cwd, cdTarget);
805
- const normalizedCwd = resolve(cwd);
806
- return isPathWithinDirectory(resolved, normalizedCwd) ? resolved : cwd;
937
+ function literalTextOf(node: TSNode): string | null {
938
+ switch (node.type) {
939
+ case "word": {
940
+ const text = node.text;
941
+ if (text === "-" || text.startsWith("~")) return null;
942
+ return text;
943
+ }
944
+ case "raw_string": {
945
+ const text = node.text;
946
+ return text.length >= 2 && text.startsWith("'") && text.endsWith("'")
947
+ ? text.slice(1, -1)
948
+ : text;
949
+ }
950
+ case "concatenation": {
951
+ let result = "";
952
+ for (let i = 0; i < node.childCount; i++) {
953
+ const child = node.child(i);
954
+ if (!child) continue;
955
+ const part = literalTextOf(child);
956
+ if (part === null) return null;
957
+ result += part;
958
+ }
959
+ return result;
960
+ }
961
+ case "string": {
962
+ let result = "";
963
+ for (let i = 0; i < node.childCount; i++) {
964
+ const child = node.child(i);
965
+ if (!child) continue;
966
+ if (child.type === '"') continue;
967
+ if (child.type !== "string_content") return null;
968
+ result += child.text;
969
+ }
970
+ return result;
971
+ }
972
+ default:
973
+ return null;
974
+ }
807
975
  }
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
54
54
  subagentSessionsDir: runtime.subagentSessionsDir,
55
55
  forwardingDir: runtime.forwardingDir,
56
56
  registry: subagentRegistry,
57
+ events: pi.events,
57
58
  requestPermissionDecisionFromUi,
58
59
  });
59
60
 
@@ -61,6 +62,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
61
62
  forwardingDir: runtime.forwardingDir,
62
63
  subagentSessionsDir: runtime.subagentSessionsDir,
63
64
  registry: subagentRegistry,
65
+ events: pi.events,
64
66
  logger: {
65
67
  writeReviewLog: runtime.writeReviewLog.bind(runtime),
66
68
  writeDebugLog: runtime.writeDebugLog.bind(runtime),