@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 +35 -0
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/polling.ts +34 -2
- package/src/handlers/gates/bash-program.ts +237 -69
- package/src/index.ts +2 -0
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +22 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/service.ts +17 -0
- package/test/bash-external-directory.test.ts +10 -9
- package/test/composition-root.test.ts +5 -0
- package/test/handlers/gates/bash-program.test.ts +109 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarding.test.ts +282 -0
- package/test/permission-prompter.test.ts +120 -0
- package/test/permission-ui-prompt.test.ts +146 -0
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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
99
|
-
* redirect-destination nodes once into raw candidate tokens,
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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([],
|
|
130
|
+
if (!tree) return new BashProgram([], []);
|
|
107
131
|
|
|
108
132
|
try {
|
|
109
|
-
const
|
|
110
|
-
const rawTokens = collectPathCandidateTokens(tree.rootNode);
|
|
133
|
+
const rawCandidates = collectPathCandidates(tree.rootNode);
|
|
111
134
|
const commandUnits = collectCommands(tree.rootNode);
|
|
112
|
-
return new BashProgram(
|
|
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.
|
|
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
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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.
|
|
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
|
-
// ──
|
|
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
|
|
789
|
+
* Walk the AST once, collecting every path-candidate token tagged with the
|
|
790
|
+
* effective working directory projected onto its position.
|
|
740
791
|
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
|
767
|
-
|
|
768
|
-
|
|
887
|
+
function isRelativeCandidate(candidate: string): boolean {
|
|
888
|
+
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
889
|
+
}
|
|
769
890
|
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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 (!
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
|
929
|
+
return null;
|
|
789
930
|
}
|
|
790
931
|
|
|
791
932
|
/**
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
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
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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),
|