@komarspn/pi-permission-system 16.0.2
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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
package/src/rule.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
2
|
+
import type { PermissionState } from "./types";
|
|
3
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provenance of a rule — which source contributed it.
|
|
7
|
+
*
|
|
8
|
+
* Config scopes: "global", "project", "agent", "project-agent".
|
|
9
|
+
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
10
|
+
* "baseline" (conditional MCP metadata auto-allow).
|
|
11
|
+
* Runtime: "session" (session approvals).
|
|
12
|
+
*/
|
|
13
|
+
export type RuleOrigin =
|
|
14
|
+
| "global"
|
|
15
|
+
| "project"
|
|
16
|
+
| "agent"
|
|
17
|
+
| "project-agent"
|
|
18
|
+
| "builtin"
|
|
19
|
+
| "baseline"
|
|
20
|
+
| "session";
|
|
21
|
+
|
|
22
|
+
/** A single permission rule — the atomic unit of policy. */
|
|
23
|
+
export interface Rule {
|
|
24
|
+
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
25
|
+
surface: string;
|
|
26
|
+
/** The match pattern: a command glob, tool name, skill name, or "*". */
|
|
27
|
+
pattern: string;
|
|
28
|
+
/** The permission decision. */
|
|
29
|
+
action: PermissionState;
|
|
30
|
+
/** Custom denial reason for deny rules (optional). */
|
|
31
|
+
reason?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
|
|
34
|
+
* Not used by evaluate(); purely informational metadata.
|
|
35
|
+
*/
|
|
36
|
+
layer?: "default" | "baseline" | "config" | "session";
|
|
37
|
+
/** Which source contributed this rule. */
|
|
38
|
+
origin: RuleOrigin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An ordered list of rules. Later rules take priority (last-match-wins). */
|
|
42
|
+
export type Ruleset = Rule[];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure permission evaluation.
|
|
46
|
+
*
|
|
47
|
+
* Returns the last rule in `rules` whose surface and pattern both
|
|
48
|
+
* wildcard-match the supplied values (last-match-wins).
|
|
49
|
+
*
|
|
50
|
+
* When no rule matches, returns a synthetic rule with `defaultAction`
|
|
51
|
+
* (defaults to "ask" — least privilege).
|
|
52
|
+
*/
|
|
53
|
+
export function evaluate(
|
|
54
|
+
surface: string,
|
|
55
|
+
pattern: string,
|
|
56
|
+
rules: Ruleset,
|
|
57
|
+
defaultAction?: PermissionState,
|
|
58
|
+
platform: NodeJS.Platform = process.platform,
|
|
59
|
+
): Rule {
|
|
60
|
+
const rule = rules.findLast((r) =>
|
|
61
|
+
ruleMatches(r, surface, pattern, platform),
|
|
62
|
+
);
|
|
63
|
+
if (rule !== undefined) return rule;
|
|
64
|
+
return {
|
|
65
|
+
surface,
|
|
66
|
+
pattern,
|
|
67
|
+
action: defaultAction ?? "ask",
|
|
68
|
+
origin: "builtin",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* On Windows, path-surface values are canonicalized + lowercased; fold the
|
|
74
|
+
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
75
|
+
* overrides still match. The surface→surface match stays exact.
|
|
76
|
+
*/
|
|
77
|
+
function pathMatchOptions(
|
|
78
|
+
surface: string,
|
|
79
|
+
platform: NodeJS.Platform,
|
|
80
|
+
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
|
81
|
+
return platform === "win32" && PATH_SURFACES.has(surface)
|
|
82
|
+
? { caseInsensitive: true, windowsSeparators: true }
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ruleMatches(
|
|
87
|
+
rule: Rule,
|
|
88
|
+
surface: string,
|
|
89
|
+
value: string,
|
|
90
|
+
platform: NodeJS.Platform,
|
|
91
|
+
): boolean {
|
|
92
|
+
const matchOptions = pathMatchOptions(surface, platform);
|
|
93
|
+
return (
|
|
94
|
+
wildcardMatch(rule.surface, surface) &&
|
|
95
|
+
wildcardMatch(rule.pattern, value, matchOptions)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Evaluate a surface against an ordered list of candidate values, stopping at
|
|
101
|
+
* the first candidate that matches a non-default rule (last-match-wins within
|
|
102
|
+
* each candidate, first-non-default-wins across candidates).
|
|
103
|
+
*
|
|
104
|
+
* Used by MCP (multi-candidate target list) and, uniformly, by all other
|
|
105
|
+
* surfaces (single-element candidate list).
|
|
106
|
+
*
|
|
107
|
+
* Returns the matched rule and the candidate value that produced it.
|
|
108
|
+
* When every candidate matches only the synthesized default, falls back to
|
|
109
|
+
* evaluating the first candidate so the caller always receives a concrete
|
|
110
|
+
* result.
|
|
111
|
+
*/
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate a surface against multiple values, returning the most restrictive
|
|
114
|
+
* non-allow result (deny > ask > allow).
|
|
115
|
+
*
|
|
116
|
+
* Used by the cross-cutting `path` surface to aggregate permission decisions
|
|
117
|
+
* across multiple file paths extracted from a single tool call or bash command.
|
|
118
|
+
*
|
|
119
|
+
* Returns `null` when all values evaluate to `allow` (no restriction).
|
|
120
|
+
* Returns the first `deny` immediately (short-circuit).
|
|
121
|
+
* Returns the first `ask` if no `deny` is found.
|
|
122
|
+
*/
|
|
123
|
+
export function evaluateMostRestrictive(
|
|
124
|
+
surface: string,
|
|
125
|
+
values: string[],
|
|
126
|
+
rules: Ruleset,
|
|
127
|
+
): { rule: Rule; value: string } | null {
|
|
128
|
+
let worst: { rule: Rule; value: string } | null = null;
|
|
129
|
+
for (const value of values) {
|
|
130
|
+
const rule = evaluate(surface, value, rules);
|
|
131
|
+
if (rule.action === "deny") return { rule, value };
|
|
132
|
+
if (rule.action === "ask" && worst?.rule.action !== "ask") {
|
|
133
|
+
worst = { rule, value };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return worst;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function evaluateFirst(
|
|
140
|
+
surface: string,
|
|
141
|
+
values: string[],
|
|
142
|
+
rules: Ruleset,
|
|
143
|
+
): { rule: Rule; value: string } {
|
|
144
|
+
for (const value of values) {
|
|
145
|
+
const rule = evaluate(surface, value, rules);
|
|
146
|
+
if (rule.layer !== "default") {
|
|
147
|
+
return { rule, value };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// All candidates matched only the synthesized default — use the first.
|
|
151
|
+
const fallbackValue = values[0] ?? "*";
|
|
152
|
+
return {
|
|
153
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
154
|
+
value: fallbackValue,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Evaluate equivalent lookup values as aliases of the same path.
|
|
160
|
+
*
|
|
161
|
+
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
162
|
+
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
163
|
+
* legacy relative rules coexist without a catch-all match on the first alias
|
|
164
|
+
* masking a later, more specific rule on another alias.
|
|
165
|
+
*/
|
|
166
|
+
export function evaluateAnyValue(
|
|
167
|
+
surface: string,
|
|
168
|
+
values: string[],
|
|
169
|
+
rules: Ruleset,
|
|
170
|
+
platform: NodeJS.Platform = process.platform,
|
|
171
|
+
): { rule: Rule; value: string } {
|
|
172
|
+
const fallbackValue = values[0] ?? "*";
|
|
173
|
+
const rule = rules.findLast((r) =>
|
|
174
|
+
values.some((value) => ruleMatches(r, surface, value, platform)),
|
|
175
|
+
);
|
|
176
|
+
if (rule !== undefined) {
|
|
177
|
+
return {
|
|
178
|
+
rule,
|
|
179
|
+
value:
|
|
180
|
+
values.find((value) => ruleMatches(rule, surface, value, platform)) ??
|
|
181
|
+
fallbackValue,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
rule: evaluate(surface, fallbackValue, rules),
|
|
186
|
+
value: fallbackValue,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mergeFlatPermissions } from "#src/permission-merge";
|
|
2
|
+
import type { RuleOrigin } from "#src/rule";
|
|
3
|
+
import type { FlatPermissionConfig, ScopeConfig } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/** Surface → (pattern → originating scope). */
|
|
6
|
+
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
7
|
+
|
|
8
|
+
/** Result of merging permission objects across scopes with provenance tracking. */
|
|
9
|
+
export interface MergedScopes {
|
|
10
|
+
/** Fully merged flat permission config (lowest → highest precedence). */
|
|
11
|
+
mergedPermission: FlatPermissionConfig;
|
|
12
|
+
/** Maps each surface to a per-pattern origin (which scope contributed it). */
|
|
13
|
+
origins: OriginMap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Merge permission objects across scopes (lowest → highest precedence) while
|
|
18
|
+
* tracking which scope contributed each (surface, pattern) entry.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors mergeFlatPermissions() semantics for origin attribution:
|
|
21
|
+
* - Both base and incoming are objects → shallow-merge: each incoming pattern
|
|
22
|
+
* is attributed to this scope; patterns the higher scope does not redefine
|
|
23
|
+
* keep their earlier origin.
|
|
24
|
+
* - Otherwise → full replacement: this scope takes over the entire surface
|
|
25
|
+
* entry, discarding all lower-scope attribution.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeScopesWithOrigins(
|
|
28
|
+
scopes: readonly (readonly [RuleOrigin, ScopeConfig])[],
|
|
29
|
+
): MergedScopes {
|
|
30
|
+
const origins: OriginMap = new Map();
|
|
31
|
+
let mergedPermission: FlatPermissionConfig = {};
|
|
32
|
+
|
|
33
|
+
for (const [scopeName, scope] of scopes) {
|
|
34
|
+
if (!scope.permission) continue;
|
|
35
|
+
|
|
36
|
+
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
37
|
+
const baseVal = mergedPermission[surface];
|
|
38
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- defensive null/type checks; config values may differ at runtime */
|
|
39
|
+
const bothObjects =
|
|
40
|
+
typeof baseVal === "object" &&
|
|
41
|
+
baseVal !== null &&
|
|
42
|
+
typeof value === "object" &&
|
|
43
|
+
value !== null;
|
|
44
|
+
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
|
|
45
|
+
|
|
46
|
+
if (bothObjects) {
|
|
47
|
+
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
48
|
+
// existing patterns from lower scopes keep their earlier origin.
|
|
49
|
+
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
50
|
+
for (const pattern of Object.keys(value)) {
|
|
51
|
+
origins.get(surface)?.set(pattern, scopeName);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
// Full replacement: this scope takes over the entire surface entry.
|
|
55
|
+
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
surfaceOrigins.set("*", scopeName);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defensive null check
|
|
59
|
+
} else if (typeof value === "object" && value !== null) {
|
|
60
|
+
for (const pattern of Object.keys(value)) {
|
|
61
|
+
surfaceOrigins.set(pattern, scopeName);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
origins.set(surface, surfaceOrigins);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { mergedPermission, origins };
|
|
72
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
4
|
+
import {
|
|
5
|
+
type PermissionsService,
|
|
6
|
+
publishPermissionsService,
|
|
7
|
+
unpublishPermissionsService,
|
|
8
|
+
} from "./service";
|
|
9
|
+
import { isRegisteredSubagentChild } from "./subagent-context";
|
|
10
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
11
|
+
|
|
12
|
+
/** The session-scoped service lifecycle that the lifecycle handler drives. */
|
|
13
|
+
export interface ServiceLifecycle {
|
|
14
|
+
activate(ctx: ExtensionContext): void;
|
|
15
|
+
teardown(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns the process-global service publication lifecycle for one extension
|
|
20
|
+
* instance.
|
|
21
|
+
*
|
|
22
|
+
* - `activate` publishes the service (skipped for registered subagent children
|
|
23
|
+
* so they never clobber the parent's slot — see #302), then emits the ready
|
|
24
|
+
* event.
|
|
25
|
+
* - `teardown` runs all session-scoped subscription cleanups in order, then
|
|
26
|
+
* unpublishes the service.
|
|
27
|
+
*/
|
|
28
|
+
export class PermissionServiceLifecycle implements ServiceLifecycle {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly service: PermissionsService,
|
|
31
|
+
private readonly registry: SubagentSessionRegistry,
|
|
32
|
+
private readonly events: PermissionEventBus,
|
|
33
|
+
private readonly subscriptions: readonly (() => void)[],
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
activate(ctx: ExtensionContext): void {
|
|
37
|
+
if (!isRegisteredSubagentChild(ctx, this.registry)) {
|
|
38
|
+
publishPermissionsService(this.service);
|
|
39
|
+
}
|
|
40
|
+
emitReadyEvent(this.events);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
teardown(): void {
|
|
44
|
+
for (const unsubscribe of this.subscriptions) {
|
|
45
|
+
unsubscribe();
|
|
46
|
+
}
|
|
47
|
+
unpublishPermissionsService(this.service);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
|
|
3
|
+
*
|
|
4
|
+
* `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
|
|
5
|
+
* module isolation (`moduleCache: false`). A consumer doing
|
|
6
|
+
* `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
|
|
7
|
+
* `getPermissionsService()` reads from the same `globalThis` slot the provider
|
|
8
|
+
* wrote to — enabling direct, synchronous, type-safe function calls.
|
|
9
|
+
*
|
|
10
|
+
* Best practice: call `getPermissionsService()` per use rather than caching the
|
|
11
|
+
* reference — this ensures resilience across `/reload` and load-order edge cases.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ToolAccessExtractor } from "./tool-access-extractor-registry";
|
|
15
|
+
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
16
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
17
|
+
|
|
18
|
+
export type {
|
|
19
|
+
ForwardedPromptContext,
|
|
20
|
+
PermissionDecisionEvent,
|
|
21
|
+
PermissionsPromptReplyData,
|
|
22
|
+
PermissionsPromptRequest,
|
|
23
|
+
PermissionsReadyEvent,
|
|
24
|
+
PermissionsRpcReply,
|
|
25
|
+
PermissionUiPromptEvent,
|
|
26
|
+
PermissionUiPromptSource,
|
|
27
|
+
} from "./permission-events";
|
|
28
|
+
export {
|
|
29
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
30
|
+
PERMISSIONS_PROTOCOL_VERSION,
|
|
31
|
+
PERMISSIONS_READY_CHANNEL,
|
|
32
|
+
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
33
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
34
|
+
} from "./permission-events";
|
|
35
|
+
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
36
|
+
|
|
37
|
+
/** Process-global key for the service slot. */
|
|
38
|
+
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Public interface exposed to other extensions via `getPermissionsService()`.
|
|
42
|
+
*
|
|
43
|
+
* Mirrors the simplified RPC signature — surface + optional value + optional
|
|
44
|
+
* agent name — and delegates to `PermissionManager.checkPermission()` with
|
|
45
|
+
* current session rules internally.
|
|
46
|
+
*/
|
|
47
|
+
export interface PermissionsService {
|
|
48
|
+
/**
|
|
49
|
+
* Query the permission policy for a surface and value.
|
|
50
|
+
*
|
|
51
|
+
* @param surface - Permission surface: "bash", "read", "mcp", "skill",
|
|
52
|
+
* "external_directory", etc.
|
|
53
|
+
* @param value - The value to evaluate: command string, tool name, skill
|
|
54
|
+
* name, or path. Omit or pass `undefined` for a
|
|
55
|
+
* surface-level query.
|
|
56
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
57
|
+
* @returns Full check result including state, matched pattern, and origin.
|
|
58
|
+
*/
|
|
59
|
+
checkPermission(
|
|
60
|
+
surface: string,
|
|
61
|
+
value?: string,
|
|
62
|
+
agentName?: string,
|
|
63
|
+
): PermissionCheckResult;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Register a custom preview formatter for a specific tool name.
|
|
67
|
+
*
|
|
68
|
+
* The formatter is consulted first inside `ToolPreviewFormatter.formatToolInputForPrompt`;
|
|
69
|
+
* returning `undefined` falls through to the built-in switch (and ultimately
|
|
70
|
+
* the JSON default).
|
|
71
|
+
*
|
|
72
|
+
* Only one formatter may be registered per tool name — a second call for the
|
|
73
|
+
* same name throws. The returned disposer unregisters the formatter.
|
|
74
|
+
*
|
|
75
|
+
* @param toolName - Exact tool name to register for (e.g. `"mcp"`, `"my-server:run"`).
|
|
76
|
+
* @param formatter - Receives the raw `input` record; return a string to use
|
|
77
|
+
* as the prompt preview, or `undefined` to decline.
|
|
78
|
+
*/
|
|
79
|
+
registerToolInputFormatter(
|
|
80
|
+
toolName: string,
|
|
81
|
+
formatter: ToolInputFormatter,
|
|
82
|
+
): () => void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register a custom access-intent extractor for a specific tool name.
|
|
86
|
+
*
|
|
87
|
+
* The extractor declares the filesystem path a tool will access so the
|
|
88
|
+
* cross-cutting `path` and `external_directory` gates can see it. Use it for
|
|
89
|
+
* tools whose path lives under a non-standard key — built-in file tools and
|
|
90
|
+
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
|
|
91
|
+
* already covered by convention without registration.
|
|
92
|
+
*
|
|
93
|
+
* The extractor receives the raw `input` record and returns the path string,
|
|
94
|
+
* or `undefined` to decline. Only one extractor may be registered per tool
|
|
95
|
+
* name — a second call for the same name throws. The returned disposer
|
|
96
|
+
* unregisters the extractor.
|
|
97
|
+
*
|
|
98
|
+
* @param toolName - Exact tool name to register for (e.g. `"ffgrep"`).
|
|
99
|
+
* @param extractor - Receives the raw `input` record; return the path string,
|
|
100
|
+
* or `undefined` to decline.
|
|
101
|
+
*/
|
|
102
|
+
registerToolAccessExtractor(
|
|
103
|
+
toolName: string,
|
|
104
|
+
extractor: ToolAccessExtractor,
|
|
105
|
+
): () => void;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Query the tool-level permission state for pre-filtering tools before
|
|
109
|
+
* creating a child session.
|
|
110
|
+
*
|
|
111
|
+
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
112
|
+
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
113
|
+
* use `checkPermission` for runtime invocation gates.
|
|
114
|
+
*
|
|
115
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
116
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
117
|
+
*/
|
|
118
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Store a `PermissionsService` on `globalThis` so other extensions can
|
|
123
|
+
* retrieve it via `getPermissionsService()`.
|
|
124
|
+
*
|
|
125
|
+
* Called at `session_start` by the top-level (parent) instance only — an
|
|
126
|
+
* in-process subagent child skips publishing so it cannot clobber the parent's
|
|
127
|
+
* service. Overwrites any previously published service, which keeps `/reload`
|
|
128
|
+
* working: a reloaded parent re-publishes its fresh service.
|
|
129
|
+
*/
|
|
130
|
+
export function publishPermissionsService(service: PermissionsService): void {
|
|
131
|
+
(globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Retrieve the published `PermissionsService`, or `undefined` if the
|
|
136
|
+
* permission-system extension has not loaded (or has been unloaded).
|
|
137
|
+
*/
|
|
138
|
+
export function getPermissionsService(): PermissionsService | undefined {
|
|
139
|
+
return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
|
|
140
|
+
| PermissionsService
|
|
141
|
+
| undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove `service` from `globalThis`, but only when the current slot still
|
|
146
|
+
* holds it (identity compare-and-delete).
|
|
147
|
+
*
|
|
148
|
+
* Called during `session_shutdown` to avoid stale references after the
|
|
149
|
+
* extension is torn down. Scoping the delete to the publishing instance keeps
|
|
150
|
+
* two cases correct:
|
|
151
|
+
*
|
|
152
|
+
* - An in-process subagent child never published the parent's service, so its
|
|
153
|
+
* shutdown is a no-op and the parent's slot survives.
|
|
154
|
+
* - A superseded `/reload` generation no longer owns the slot, so its late
|
|
155
|
+
* shutdown cannot wipe the new generation's freshly published service.
|
|
156
|
+
*/
|
|
157
|
+
export function unpublishPermissionsService(service: PermissionsService): void {
|
|
158
|
+
if (getPermissionsService() !== service) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
162
|
+
delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
|
|
163
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value object for a session-scoped approval: one surface, one-or-more patterns.
|
|
3
|
+
*
|
|
4
|
+
* Owned by gate descriptors and passed to the session store — the runner never
|
|
5
|
+
* needs to know whether there is one pattern or many.
|
|
6
|
+
*/
|
|
7
|
+
export class SessionApproval {
|
|
8
|
+
private constructor(
|
|
9
|
+
readonly surface: string,
|
|
10
|
+
readonly patterns: readonly string[],
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/** Create an approval for a single pattern (the common case). */
|
|
14
|
+
static single(surface: string, pattern: string): SessionApproval {
|
|
15
|
+
return new SessionApproval(surface, [pattern]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an approval for multiple patterns (e.g. bash external-directory
|
|
20
|
+
* gates that cover several uncovered paths in one prompt).
|
|
21
|
+
*/
|
|
22
|
+
static multiple(
|
|
23
|
+
surface: string,
|
|
24
|
+
patterns: readonly string[],
|
|
25
|
+
): SessionApproval {
|
|
26
|
+
return new SessionApproval(surface, [...patterns]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Representative pattern for the interactive prompt — the first, if any. */
|
|
30
|
+
get representativePattern(): string | undefined {
|
|
31
|
+
return this.patterns[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Single-pattern shape `applyPermissionGate` echoes back to the caller.
|
|
36
|
+
* Returns `undefined` when patterns is empty (degenerate case).
|
|
37
|
+
*/
|
|
38
|
+
toGateApproval(): { surface: string; pattern: string } | undefined {
|
|
39
|
+
const pattern = this.representativePattern;
|
|
40
|
+
if (pattern === undefined) return undefined;
|
|
41
|
+
return { surface: this.surface, pattern };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { DEBUG_LOG_FILENAME, REVIEW_LOG_FILENAME } from "./config-paths";
|
|
3
|
+
import {
|
|
4
|
+
ensurePermissionSystemLogsDirectory,
|
|
5
|
+
type PermissionSystemExtensionConfig,
|
|
6
|
+
} from "./extension-config";
|
|
7
|
+
import {
|
|
8
|
+
createPermissionSystemLogger,
|
|
9
|
+
type PermissionSystemLogger,
|
|
10
|
+
} from "./logging";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Narrowest logging seam — consumers that only write review-log entries.
|
|
14
|
+
* Injected into `PermissionPrompter` and the RPC handlers.
|
|
15
|
+
*/
|
|
16
|
+
export interface ReviewLogger {
|
|
17
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Logging seam for consumers that write both debug and review entries.
|
|
22
|
+
* Injected into `ConfigStore` and `PermissionForwarder`.
|
|
23
|
+
*/
|
|
24
|
+
export interface DebugReviewLogger extends ReviewLogger {
|
|
25
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Unified logging + notification surface for handler deps.
|
|
30
|
+
*
|
|
31
|
+
* Replaces three separate logging fields (`writeDebugLog`,
|
|
32
|
+
* `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
|
|
33
|
+
* This is an intermediate abstraction on the path to PermissionSession (#129).
|
|
34
|
+
*/
|
|
35
|
+
export interface SessionLogger extends DebugReviewLogger {
|
|
36
|
+
warn(message: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Narrow dependencies for constructing a {@link SessionLogger}. */
|
|
40
|
+
export interface SessionLoggerDeps {
|
|
41
|
+
/** Root logs directory; the debug + review log file paths derive from it. */
|
|
42
|
+
globalLogsDir: string;
|
|
43
|
+
/** Reads current config for the debug/review write toggles (call-time). */
|
|
44
|
+
getConfig: () => PermissionSystemExtensionConfig;
|
|
45
|
+
/** Surfaces a warning message to the user; called at warn/IO-failure time. */
|
|
46
|
+
notify: (message: string) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Concrete `SessionLogger` implementation.
|
|
51
|
+
*
|
|
52
|
+
* Composes the JSONL log writer, privately owns the IO-failure warning
|
|
53
|
+
* dedup Set, and routes both IO-failure warnings and explicit warn() calls
|
|
54
|
+
* through the injected notify sink. No ExtensionRuntime reference required.
|
|
55
|
+
*/
|
|
56
|
+
export class PermissionSessionLogger implements SessionLogger {
|
|
57
|
+
private readonly writer: PermissionSystemLogger;
|
|
58
|
+
private readonly reported = new Set<string>();
|
|
59
|
+
private readonly notify: (message: string) => void;
|
|
60
|
+
|
|
61
|
+
constructor(deps: SessionLoggerDeps) {
|
|
62
|
+
this.writer = createPermissionSystemLogger({
|
|
63
|
+
getConfig: deps.getConfig,
|
|
64
|
+
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
65
|
+
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
66
|
+
ensureLogsDirectory: () =>
|
|
67
|
+
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
|
|
68
|
+
});
|
|
69
|
+
this.notify = deps.notify;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
debug(event: string, details?: Record<string, unknown>): void {
|
|
73
|
+
const warning = this.writer.debug(event, details);
|
|
74
|
+
if (warning) this.reportOnce(warning);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
review(event: string, details?: Record<string, unknown>): void {
|
|
78
|
+
const warning = this.writer.review(event, details);
|
|
79
|
+
if (warning) this.reportOnce(warning);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
warn(message: string): void {
|
|
83
|
+
this.notify(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private reportOnce(warning: string): void {
|
|
87
|
+
if (this.reported.has(warning)) return;
|
|
88
|
+
this.reported.add(warning);
|
|
89
|
+
this.notify(warning);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { dirname, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { Ruleset } from "./rule";
|
|
4
|
+
import type { SessionApproval } from "./session-approval";
|
|
5
|
+
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ephemeral in-memory store of session-scoped permission approvals.
|
|
9
|
+
*
|
|
10
|
+
* Each approval is stored as a `Rule` with `action: "allow"`, making the
|
|
11
|
+
* ruleset directly usable with `evaluate()` — no custom matching engine needed.
|
|
12
|
+
*
|
|
13
|
+
* Cleared on session_shutdown — never persisted to disk.
|
|
14
|
+
*/
|
|
15
|
+
export class SessionRules implements SessionApprovalRecorder {
|
|
16
|
+
private rules: Ruleset = [];
|
|
17
|
+
|
|
18
|
+
/** Record a wildcard pattern as approved for the given surface. */
|
|
19
|
+
approve(surface: string, pattern: string): void {
|
|
20
|
+
this.rules.push({
|
|
21
|
+
surface,
|
|
22
|
+
pattern,
|
|
23
|
+
action: "allow",
|
|
24
|
+
layer: "session",
|
|
25
|
+
origin: "session",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Return a defensive copy of the current session ruleset. */
|
|
30
|
+
getRuleset(): Ruleset {
|
|
31
|
+
return [...this.rules];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Record all patterns from a `SessionApproval` value object.
|
|
36
|
+
*
|
|
37
|
+
* The loop lives here so callers never need to know whether an approval
|
|
38
|
+
* carries one pattern or many — they just tell the store to record it.
|
|
39
|
+
*/
|
|
40
|
+
recordSessionApproval(approval: SessionApproval): void {
|
|
41
|
+
for (const pattern of approval.patterns) {
|
|
42
|
+
this.approve(approval.surface, pattern);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove all session approvals. */
|
|
47
|
+
clear(): void {
|
|
48
|
+
this.rules = [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Derive the wildcard glob pattern to approve from a normalized path.
|
|
54
|
+
*
|
|
55
|
+
* Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
|
|
56
|
+
* all paths under the approved directory — identical semantics to the former
|
|
57
|
+
* `SessionApprovalCache` prefix matching, using the unified wildcard engine.
|
|
58
|
+
*
|
|
59
|
+
* For paths that already end with a separator (directories), the separator
|
|
60
|
+
* is treated as the directory boundary and `*` is appended directly.
|
|
61
|
+
*
|
|
62
|
+
* The path is expected to be the canonical (cwd-resolved, absolute) form used
|
|
63
|
+
* for policy matching, so the derived pattern matches the same policy values a
|
|
64
|
+
* later tool call produces. Callers that hold a working directory resolve the
|
|
65
|
+
* path to that form first; the function itself stays free of cwd state.
|
|
66
|
+
*/
|
|
67
|
+
export function deriveApprovalPattern(normalizedPath: string): string {
|
|
68
|
+
// If the path already ends with a separator, it's a directory — glob its contents.
|
|
69
|
+
if (normalizedPath.endsWith(sep)) {
|
|
70
|
+
return `${normalizedPath}*`;
|
|
71
|
+
}
|
|
72
|
+
const dir = dirname(normalizedPath);
|
|
73
|
+
if (dir === normalizedPath) {
|
|
74
|
+
// Root path — dirname('/') === '/'
|
|
75
|
+
return `${dir}*`;
|
|
76
|
+
}
|
|
77
|
+
const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
78
|
+
return `${prefix}*`;
|
|
79
|
+
}
|