@gotgenes/pi-permission-system 17.1.1 → 18.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 +26 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +6 -3
- package/src/access-intent/bash/program.ts +0 -4
- package/src/handlers/gates/tool-call-gate-pipeline.ts +61 -22
- package/src/handlers/gates/tool.ts +10 -14
- package/src/index.ts +8 -6
- package/src/input-normalizer.ts +54 -56
- package/src/permission-event-rpc.ts +21 -15
- package/src/permission-manager.ts +3 -6
- package/src/permission-session.ts +0 -5
- package/src/permissions-service.ts +33 -12
- package/test/access-intent/bash/program.test.ts +1 -1
- package/test/composition-root.test.ts +22 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +73 -0
- package/test/handlers/gates/tool.test.ts +25 -16
- package/test/helpers/gate-fixtures.ts +0 -3
- package/test/input-normalizer.test.ts +163 -270
- package/test/path-utils.test.ts +1 -1
- package/test/permission-event-rpc.test.ts +80 -65
- package/test/permission-manager-unified.test.ts +134 -145
- package/test/permissions-service.test.ts +84 -72
- package/test/service.test.ts +56 -103
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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
|
+
## [18.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.1...pi-permission-system-v18.0.0) (2026-06-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** A permissions:rpc:check query for a `path` / `external_directory` / path-bearing surface now matches the canonical (symlink-resolved) alias, and a `path` / path-bearing query now evaluates the supplied path instead of collapsing to `*`.
|
|
14
|
+
* **pi-permission-system:** A service (`getPermissionsService().checkPermission`) query for a `path` / `external_directory` / path-bearing surface now matches the canonical (symlink-resolved) alias, and a `path` / path-bearing query now evaluates the supplied path instead of collapsing to `*`. A symlinked path can now match a rule on its canonical target.
|
|
15
|
+
* **pi-permission-system:** a per-tool path rule (e.g. `read: deny *.env`) now also fires when a symlink's resolved target matches the pattern, where previously only the lexically-referenced spelling matched. A symlink alias can no longer evade a per-tool deny/allow.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** match the canonical form on service path queries ([be4a3e7](https://github.com/gotgenes/pi-packages/commit/be4a3e7f48e700db4c667ce8176459a1e89820b4))
|
|
20
|
+
* **pi-permission-system:** match the canonical form on the per-tool path gate ([ad36e78](https://github.com/gotgenes/pi-packages/commit/ad36e7860084be7692cb142f50c8818bd013ec38))
|
|
21
|
+
* **pi-permission-system:** match the canonical form on the RPC check query ([bb04ca5](https://github.com/gotgenes/pi-packages/commit/bb04ca5d0bae265570144f7d76dee2bad9269f94))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** remove unused join import; annotate closed findings ([#504](https://github.com/gotgenes/pi-packages/issues/504)) ([eb7c7b2](https://github.com/gotgenes/pi-packages/commit/eb7c7b298e21358d831892545b5dd8d3e9fb340a))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** document canonical per-tool path matching ([bafa492](https://github.com/gotgenes/pi-packages/commit/bafa492cf8517902aea5a44d6ea59d4b33e7f754))
|
|
32
|
+
* **pi-permission-system:** document canonical service/RPC path matching ([35c36fa](https://github.com/gotgenes/pi-packages/commit/35c36fa9f7954aa84eb468954660b5857e067be3))
|
|
33
|
+
|
|
8
34
|
## [17.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v17.1.0...pi-permission-system-v17.1.1) (2026-06-29)
|
|
9
35
|
|
|
10
36
|
|
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ A `path` pattern matches both the path as the agent references it and its canoni
|
|
|
73
73
|
|
|
74
74
|
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
75
75
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
|
76
|
+
Like the cross-cutting `path` surface, per-tool patterns match both the referenced path and its canonical (symlink-resolved) form, so a per-tool deny resists symlink-alias evasion.
|
|
76
77
|
When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
|
|
77
78
|
|
|
78
79
|
The `external_directory` surface is the CWD-boundary gate: it decides whether reaching **outside** the working tree is allowed, and accepts a pattern map so you can allow specific outside-CWD directories without opening up all external access.
|
package/package.json
CHANGED
|
@@ -32,9 +32,12 @@ export interface PathValuesAccessIntent {
|
|
|
32
32
|
/**
|
|
33
33
|
* An `AccessPath` value object for a path-shaped surface.
|
|
34
34
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Built for every path-shaped surface: the cross-cutting `path` and
|
|
36
|
+
* `external_directory` gates, the per-tool path-bearing surfaces
|
|
37
|
+
* (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy
|
|
38
|
+
* queries for those surfaces (#503). Lets `AccessPath` flow into the resolver
|
|
39
|
+
* as a first-class variant so the resolver — not the producer — asks it for
|
|
40
|
+
* `matchValues()` (Tell-Don't-Ask).
|
|
38
41
|
*/
|
|
39
42
|
export interface AccessPathAccessIntent {
|
|
40
43
|
kind: "access-path";
|
|
@@ -73,10 +73,6 @@ export class BashProgram {
|
|
|
73
73
|
* back to the whole command so the surface is never evaluated weaker than
|
|
74
74
|
* before.
|
|
75
75
|
*/
|
|
76
|
-
// Used by resolveBashCommandCheck (bash-command.ts) and tests. Fallow's
|
|
77
|
-
// syntactic analysis cannot resolve the static-factory return type (private
|
|
78
|
-
// ctor), so it reports a false positive here.
|
|
79
|
-
// fallow-ignore-next-line unused-class-member
|
|
80
76
|
commands(): BashCommand[] {
|
|
81
77
|
return [...this.commandUnits];
|
|
82
78
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
3
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
4
|
+
import { getPathBearingToolPath } from "#src/path-utils";
|
|
3
5
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
6
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
5
7
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
ToolPreviewFormatter,
|
|
9
11
|
type ToolPreviewFormatterOptions,
|
|
10
12
|
} from "#src/tool-preview-formatter";
|
|
13
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
11
14
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
12
15
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
13
16
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
@@ -39,8 +42,6 @@ export interface ToolCallGateInputs {
|
|
|
39
42
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
40
43
|
/** The session's path normalizer (platform + cwd baked in). */
|
|
41
44
|
getPathNormalizer(): PathNormalizer;
|
|
42
|
-
/** The host platform injected at the composition root. */
|
|
43
|
-
getPlatform(): NodeJS.Platform;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
@@ -81,7 +82,6 @@ export class ToolCallGatePipeline {
|
|
|
81
82
|
);
|
|
82
83
|
|
|
83
84
|
const infraDirs = this.inputs.getInfrastructureReadDirs();
|
|
84
|
-
const platform = this.inputs.getPlatform();
|
|
85
85
|
|
|
86
86
|
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
|
|
87
87
|
() =>
|
|
@@ -101,29 +101,17 @@ export class ToolCallGatePipeline {
|
|
|
101
101
|
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
|
|
102
102
|
() => describeBashPathGate(tcc, bashProgram, this.resolver),
|
|
103
103
|
() => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
? resolveBashCommandCheck(
|
|
111
|
-
command ?? "",
|
|
112
|
-
bashProgram.commands(),
|
|
113
|
-
tcc.agentName ?? undefined,
|
|
114
|
-
this.resolver,
|
|
115
|
-
)
|
|
116
|
-
: this.resolver.resolve({
|
|
117
|
-
kind: "tool",
|
|
118
|
-
surface: tcc.toolName,
|
|
119
|
-
input: tcc.input,
|
|
120
|
-
agentName: tcc.agentName ?? undefined,
|
|
121
|
-
});
|
|
104
|
+
const { toolCheck, accessPath } = this.resolvePerToolCheck(
|
|
105
|
+
tcc,
|
|
106
|
+
bashProgram,
|
|
107
|
+
command,
|
|
108
|
+
normalizer,
|
|
109
|
+
);
|
|
122
110
|
const toolDescriptor = describeToolGate(
|
|
123
111
|
tcc,
|
|
124
112
|
toolCheck,
|
|
125
113
|
formatter,
|
|
126
|
-
|
|
114
|
+
accessPath,
|
|
127
115
|
);
|
|
128
116
|
toolDescriptor.preCheck = toolCheck;
|
|
129
117
|
return toolDescriptor;
|
|
@@ -143,4 +131,55 @@ export class ToolCallGatePipeline {
|
|
|
143
131
|
|
|
144
132
|
return { action: "allow" };
|
|
145
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the per-tool gate's check, choosing the intent by tool shape:
|
|
137
|
+
* bash chains its sub-commands; a path-bearing tool with a path emits an
|
|
138
|
+
* `access-path` intent (so the per-tool surface matches lexical ∪ canonical,
|
|
139
|
+
* #502); every other tool (and a path-bearing tool with no path) keeps the
|
|
140
|
+
* raw `tool` intent the manager normalizes.
|
|
141
|
+
*
|
|
142
|
+
* Returns the `AccessPath` alongside the check so `describeToolGate` derives
|
|
143
|
+
* the session-approval value from `accessPath.value()`.
|
|
144
|
+
*/
|
|
145
|
+
private resolvePerToolCheck(
|
|
146
|
+
tcc: ToolCallContext,
|
|
147
|
+
bashProgram: BashProgram | null,
|
|
148
|
+
command: string | null,
|
|
149
|
+
normalizer: PathNormalizer,
|
|
150
|
+
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
|
151
|
+
if (tcc.toolName === "bash" && bashProgram) {
|
|
152
|
+
return {
|
|
153
|
+
toolCheck: resolveBashCommandCheck(
|
|
154
|
+
command ?? "",
|
|
155
|
+
bashProgram.commands(),
|
|
156
|
+
tcc.agentName ?? undefined,
|
|
157
|
+
this.resolver,
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const filePath = getPathBearingToolPath(tcc.toolName, tcc.input);
|
|
163
|
+
if (filePath !== null) {
|
|
164
|
+
const accessPath = normalizer.forPath(filePath);
|
|
165
|
+
return {
|
|
166
|
+
accessPath,
|
|
167
|
+
toolCheck: this.resolver.resolve({
|
|
168
|
+
kind: "access-path",
|
|
169
|
+
surface: tcc.toolName,
|
|
170
|
+
path: accessPath,
|
|
171
|
+
agentName: tcc.agentName ?? undefined,
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
toolCheck: this.resolver.resolve({
|
|
178
|
+
kind: "tool",
|
|
179
|
+
surface: tcc.toolName,
|
|
180
|
+
input: tcc.input,
|
|
181
|
+
agentName: tcc.agentName ?? undefined,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
146
185
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
normalizePathForComparison,
|
|
4
|
-
PATH_BEARING_TOOLS,
|
|
5
|
-
} from "#src/path-utils";
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
|
|
6
3
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
7
4
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
8
5
|
import { SessionApproval } from "#src/session-approval";
|
|
@@ -16,20 +13,19 @@ import type { ToolCallContext } from "./types";
|
|
|
16
13
|
* Derive the value used for session-approval pattern suggestions.
|
|
17
14
|
*
|
|
18
15
|
* Bash → command string; MCP → qualified target;
|
|
19
|
-
* path-bearing tools → the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* path-bearing tools → the `AccessPath`'s lexical absolute form (`value()`),
|
|
17
|
+
* so the suggested pattern matches the policy values a later call produces;
|
|
18
|
+
* others (or a path-bearing tool with no path) → catch-all wildcard.
|
|
22
19
|
*/
|
|
23
20
|
function deriveSuggestionValue(
|
|
24
21
|
tcc: ToolCallContext,
|
|
25
22
|
check: PermissionCheckResult,
|
|
26
|
-
|
|
23
|
+
accessPath?: AccessPath,
|
|
27
24
|
): string {
|
|
28
25
|
if (tcc.toolName === "bash") return check.command ?? "";
|
|
29
26
|
if (tcc.toolName === "mcp") return check.target ?? "mcp";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return normalizePathForComparison(path, tcc.cwd, platform);
|
|
27
|
+
if (accessPath) return accessPath.value();
|
|
28
|
+
return "*";
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
/**
|
|
@@ -42,7 +38,7 @@ export function describeToolGate(
|
|
|
42
38
|
tcc: ToolCallContext,
|
|
43
39
|
check: PermissionCheckResult,
|
|
44
40
|
formatter: ToolPreviewFormatter,
|
|
45
|
-
|
|
41
|
+
accessPath?: AccessPath,
|
|
46
42
|
): GateDescriptor {
|
|
47
43
|
const permissionLogContext = formatter.getPermissionLogContext(
|
|
48
44
|
check,
|
|
@@ -53,7 +49,7 @@ export function describeToolGate(
|
|
|
53
49
|
// Compute session approval suggestion for the "for this session" option.
|
|
54
50
|
const suggestion = suggestSessionPattern(
|
|
55
51
|
tcc.toolName,
|
|
56
|
-
deriveSuggestionValue(tcc, check,
|
|
52
|
+
deriveSuggestionValue(tcc, check, accessPath),
|
|
57
53
|
);
|
|
58
54
|
|
|
59
55
|
const askMessage = formatAskPrompt(
|
package/src/index.ts
CHANGED
|
@@ -134,17 +134,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
134
134
|
),
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
// Resolver composes the manager + session ruleset and owns the
|
|
138
|
+
// access-path → path-values unwrap; the RPC and service route their policy
|
|
139
|
+
// queries through it, so it is constructed before both.
|
|
140
|
+
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
141
|
+
|
|
137
142
|
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
|
138
|
-
|
|
139
|
-
sessionRules,
|
|
143
|
+
resolver,
|
|
140
144
|
session,
|
|
141
145
|
requestPermissionDecisionFromUi,
|
|
142
146
|
logger,
|
|
143
147
|
});
|
|
144
148
|
|
|
145
149
|
const permissionsService = new LocalPermissionsService(
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
resolver,
|
|
151
|
+
session,
|
|
148
152
|
formatterRegistry,
|
|
149
153
|
accessExtractorRegistry,
|
|
150
154
|
);
|
|
@@ -174,8 +178,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
174
178
|
setActive: (names: string[]) => pi.setActiveTools(names),
|
|
175
179
|
};
|
|
176
180
|
|
|
177
|
-
const resolver = new PermissionResolver(permissionManager, sessionRules);
|
|
178
|
-
|
|
179
181
|
const audit = new DecisionAudit();
|
|
180
182
|
const lifecycle = new SessionLifecycleHandler(
|
|
181
183
|
session,
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,25 +1,60 @@
|
|
|
1
|
+
import type { AccessIntent } from "./access-intent/access-intent";
|
|
1
2
|
import { stripBashCommentLines } from "./bash-arity";
|
|
2
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
|
-
import {
|
|
4
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
5
|
+
import { PATH_SURFACES } from "./path-utils";
|
|
4
6
|
import { getNonEmptyString, toRecord } from "./value-guards";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
9
|
+
* Build the {@link AccessIntent} an external policy query (the `Symbol.for()`
|
|
10
|
+
* service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
|
|
11
|
+
* pair.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing
|
|
14
|
+
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an
|
|
15
|
+
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
|
|
16
|
+
* (symlink-resolved) set — at parity with the gates (#486, #502). Every other
|
|
17
|
+
* surface, and any value-less surface-level query, keeps the `tool` intent so
|
|
18
|
+
* the manager's `normalizeInput` `["*"]` fallback is preserved.
|
|
19
|
+
*/
|
|
20
|
+
export function buildAccessIntentForSurface(
|
|
21
|
+
surface: string,
|
|
22
|
+
value: string | undefined,
|
|
23
|
+
normalizer: PathNormalizer,
|
|
24
|
+
agentName: string | undefined,
|
|
25
|
+
): AccessIntent {
|
|
26
|
+
const pathValue = getNonEmptyString(value);
|
|
27
|
+
if (pathValue !== null && PATH_SURFACES.has(surface)) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "access-path",
|
|
30
|
+
surface,
|
|
31
|
+
path: normalizer.forPath(pathValue),
|
|
32
|
+
agentName,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
kind: "tool",
|
|
37
|
+
surface,
|
|
38
|
+
input: buildInputForSurface(surface, value),
|
|
39
|
+
agentName,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Construct a surface-appropriate input object from a raw value string for the
|
|
45
|
+
* `tool`-intent branch of {@link buildAccessIntentForSurface} (the non-path
|
|
46
|
+
* surfaces and value-less path queries).
|
|
12
47
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
48
|
+
* This is the inverse of `normalizeInput()` — it builds the minimal input
|
|
49
|
+
* object that the manager expects for a given surface, from a single string
|
|
50
|
+
* value.
|
|
16
51
|
*
|
|
17
52
|
* Note: MCP inputs are complex (server name + tool name derivation). Callers
|
|
18
53
|
* providing an MCP surface receive a best-effort policy evaluation using the
|
|
19
54
|
* value as a pre-qualified target string. Pass the fully-qualified target
|
|
20
55
|
* (e.g. "exa:search" or "exa") directly.
|
|
21
56
|
*/
|
|
22
|
-
|
|
57
|
+
function buildInputForSurface(
|
|
23
58
|
surface: string,
|
|
24
59
|
value: string | undefined,
|
|
25
60
|
): unknown {
|
|
@@ -51,12 +86,16 @@ export interface NormalizedInput {
|
|
|
51
86
|
resultExtras: Record<string, unknown>;
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
55
|
-
|
|
56
89
|
/**
|
|
57
90
|
* Map a raw tool invocation to the surface/values/extras triple needed by
|
|
58
91
|
* `checkPermission()`.
|
|
59
92
|
*
|
|
93
|
+
* Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
|
|
94
|
+
* (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`,
|
|
95
|
+
* `ls`) now route through the access-path gate (#502) and service/RPC builder
|
|
96
|
+
* (#503) before reaching the manager, so they never arrive here with a real
|
|
97
|
+
* path value — all fall through to the extension catch-all `["*"]`.
|
|
98
|
+
*
|
|
60
99
|
* @param toolName - Normalized (trimmed) tool name from the tool-call event.
|
|
61
100
|
* @param input - Raw input payload from the tool-call event.
|
|
62
101
|
* @param configuredMcpServerNames - Ordered list of MCP server names from the
|
|
@@ -66,18 +105,7 @@ export function normalizeInput(
|
|
|
66
105
|
toolName: string,
|
|
67
106
|
input: unknown,
|
|
68
107
|
configuredMcpServerNames: readonly string[],
|
|
69
|
-
platform: NodeJS.Platform,
|
|
70
|
-
cwd?: string,
|
|
71
108
|
): NormalizedInput {
|
|
72
|
-
// --- Special surfaces (path, external_directory) ---
|
|
73
|
-
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
74
|
-
return {
|
|
75
|
-
surface: toolName,
|
|
76
|
-
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
77
|
-
resultExtras: {},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
109
|
// --- Skill ---
|
|
82
110
|
if (toolName === "skill") {
|
|
83
111
|
const record = toRecord(input);
|
|
@@ -120,43 +148,13 @@ export function normalizeInput(
|
|
|
120
148
|
};
|
|
121
149
|
}
|
|
122
150
|
|
|
123
|
-
// ---
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
values: normalizePathSurfaceValues(input, platform, cwd),
|
|
128
|
-
resultExtras: {},
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// --- Extension tools (non-path-bearing) ---
|
|
151
|
+
// --- All other surfaces (path-bearing tools and extension tools) ---
|
|
152
|
+
// Path-bearing tools with a present path never reach here — the gate emits
|
|
153
|
+
// an access-path intent (#502). Missing-path and extension-tool cases both
|
|
154
|
+
// collapse to the surface catch-all.
|
|
133
155
|
return {
|
|
134
156
|
surface: toolName,
|
|
135
157
|
values: ["*"],
|
|
136
158
|
resultExtras: {},
|
|
137
159
|
};
|
|
138
160
|
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Extract and normalize the path lookup values shared by every path surface
|
|
142
|
-
* (`path`, `external_directory`, and the path-bearing tools).
|
|
143
|
-
*
|
|
144
|
-
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
145
|
-
* `"*"`. When CWD is known, a relative path also produces a normalized
|
|
146
|
-
* absolute policy value and a project-relative alias while keeping its legacy
|
|
147
|
-
* relative value, so values match home- and cwd-anchored patterns
|
|
148
|
-
* symmetrically with how the patterns themselves are expanded (#350).
|
|
149
|
-
*
|
|
150
|
-
* Only `input.path` is read — policy values are never sourced from any other
|
|
151
|
-
* (potentially attacker-controlled) field on the raw tool input.
|
|
152
|
-
*/
|
|
153
|
-
function normalizePathSurfaceValues(
|
|
154
|
-
input: unknown,
|
|
155
|
-
platform: NodeJS.Platform,
|
|
156
|
-
cwd?: string,
|
|
157
|
-
): string[] {
|
|
158
|
-
const path = getNonEmptyString(toRecord(input).path);
|
|
159
|
-
if (path === null) return ["*"];
|
|
160
|
-
const values = getPathPolicyValues(path, cwd ? { cwd } : {}, platform);
|
|
161
|
-
return values.length > 0 ? values : ["*"];
|
|
162
|
-
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* permission prompts without importing this package.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import {
|
|
10
|
+
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
11
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
11
12
|
import type {
|
|
12
13
|
PermissionPromptDecision,
|
|
13
14
|
RequestPermissionOptions,
|
|
@@ -26,22 +27,26 @@ import {
|
|
|
26
27
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
27
28
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
29
|
} from "./permission-events";
|
|
29
|
-
import type {
|
|
30
|
+
import type { ScopedPermissionResolver } from "./permission-resolver";
|
|
30
31
|
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
31
32
|
import type { ReviewLogger } from "./session-logger";
|
|
32
|
-
import type { SessionRules } from "./session-rules";
|
|
33
33
|
|
|
34
34
|
/** Dependencies injected into the RPC handler registry. */
|
|
35
35
|
export interface PermissionRpcDeps {
|
|
36
|
-
/** The shared PermissionManager instance. */
|
|
37
|
-
permissionManager: Pick<ScopedPermissionManager, "check">;
|
|
38
|
-
/** The shared SessionRules instance. */
|
|
39
|
-
sessionRules: Pick<SessionRules, "getRuleset">;
|
|
40
36
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
37
|
+
* The shared resolver: answers an access intent, composing the session
|
|
38
|
+
* ruleset and unwrapping `access-path` → `path-values` internally so the
|
|
39
|
+
* RPC check matches the same lexical ∪ canonical set the gates do (#503).
|
|
43
40
|
*/
|
|
44
|
-
|
|
41
|
+
resolver: Pick<ScopedPermissionResolver, "resolve">;
|
|
42
|
+
/**
|
|
43
|
+
* Narrow session view: runtime context for the prompt handler (hasUI / UI
|
|
44
|
+
* dialog) and the cwd-bound path normalizer for path-surface check queries.
|
|
45
|
+
*/
|
|
46
|
+
session: {
|
|
47
|
+
getRuntimeContext(): ExtensionContext | null;
|
|
48
|
+
getPathNormalizer(): PathNormalizer;
|
|
49
|
+
};
|
|
45
50
|
/** Show the interactive permission dialog in the parent session UI. */
|
|
46
51
|
requestPermissionDecisionFromUi(
|
|
47
52
|
ui: ExtensionContext["ui"],
|
|
@@ -107,12 +112,13 @@ function handleCheckRpc(
|
|
|
107
112
|
return;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
const intent = buildAccessIntentForSurface(
|
|
116
|
+
surface,
|
|
117
|
+
value,
|
|
118
|
+
deps.session.getPathNormalizer(),
|
|
119
|
+
agentName ?? undefined,
|
|
115
120
|
);
|
|
121
|
+
const result = deps.resolver.resolve(intent);
|
|
116
122
|
|
|
117
123
|
const data: PermissionsCheckReplyData = {
|
|
118
124
|
result: result.state,
|
|
@@ -96,7 +96,6 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
|
96
96
|
export class PermissionManager implements ScopedPermissionManager {
|
|
97
97
|
private readonly agentDir: string | undefined;
|
|
98
98
|
private readonly platform: NodeJS.Platform;
|
|
99
|
-
private currentCwd: string | undefined;
|
|
100
99
|
private loader: PolicyLoader;
|
|
101
100
|
private readonly resolvedPermissionsCache = new Map<
|
|
102
101
|
string,
|
|
@@ -123,8 +122,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
123
122
|
* built with explicit paths), only the cache is cleared.
|
|
124
123
|
*/
|
|
125
124
|
configureForCwd(cwd: string | undefined | null): void {
|
|
126
|
-
this.currentCwd =
|
|
127
|
-
typeof cwd === "string" && cwd.trim().length > 0 ? cwd : undefined;
|
|
128
125
|
if (this.agentDir !== undefined) {
|
|
129
126
|
this.loader = new FilePolicyLoader(
|
|
130
127
|
derivePolicyLoaderOptions(this.agentDir, cwd),
|
|
@@ -248,7 +245,9 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
248
245
|
/**
|
|
249
246
|
* Unified resolution entry point — dispatches on intent kind.
|
|
250
247
|
*
|
|
251
|
-
* `"tool"` → normalizes raw input through `normalizeInput
|
|
248
|
+
* `"tool"` → normalizes raw input through `normalizeInput` (bash, skill, mcp,
|
|
249
|
+
* extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
|
|
250
|
+
* the access-path gate (#502) or service/RPC builder (#503).
|
|
252
251
|
* `"path-values"` → evaluates the precomputed values directly.
|
|
253
252
|
*/
|
|
254
253
|
check(
|
|
@@ -280,8 +279,6 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
280
279
|
toolName,
|
|
281
280
|
intent.input,
|
|
282
281
|
this.loader.getConfiguredMcpServerNames(),
|
|
283
|
-
this.platform,
|
|
284
|
-
this.currentCwd,
|
|
285
282
|
);
|
|
286
283
|
return buildCheckResult(
|
|
287
284
|
surface,
|
|
@@ -85,11 +85,6 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
85
85
|
return this.context;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** The host platform injected at the composition root. */
|
|
89
|
-
getPlatform(): NodeJS.Platform {
|
|
90
|
-
return this.platform;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
88
|
// ── UI notifications ────────────────────────────────────────────────────
|
|
94
89
|
|
|
95
90
|
/** Surface a warning message to the user via the active UI context, if any. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
3
|
+
import type { PathNormalizer } from "./path-normalizer";
|
|
3
4
|
import type { PermissionsService } from "./service";
|
|
4
|
-
import type { SessionRules } from "./session-rules";
|
|
5
5
|
import type {
|
|
6
6
|
ToolAccessExtractor,
|
|
7
7
|
ToolAccessExtractorRegistrar,
|
|
@@ -10,18 +10,37 @@ import type {
|
|
|
10
10
|
ToolInputFormatter,
|
|
11
11
|
ToolInputFormatterRegistrar,
|
|
12
12
|
} from "./tool-input-formatter-registry";
|
|
13
|
+
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolution surface the service needs: answer a gate-style {@link AccessIntent}
|
|
17
|
+
* (composing the session ruleset internally) and report a tool-level state.
|
|
18
|
+
* `PermissionResolver` satisfies it.
|
|
19
|
+
*/
|
|
20
|
+
interface ResolverForService {
|
|
21
|
+
resolve(intent: AccessIntent): PermissionCheckResult;
|
|
22
|
+
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Narrow session view: hands out the cwd-bound path normalizer. */
|
|
26
|
+
interface PathNormalizerProvider {
|
|
27
|
+
getPathNormalizer(): PathNormalizer;
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
/**
|
|
15
31
|
* In-process implementation of the cross-extension {@link PermissionsService}.
|
|
16
32
|
*
|
|
17
33
|
* Constructed once in the composition root and backed by the single shared
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
34
|
+
* `PermissionResolver` and `PermissionSession` that the gates also use — so
|
|
35
|
+
* service queries and gate-path decisions see the same state. Path-shaped
|
|
36
|
+
* surface queries route through the resolver as an `access-path` intent, so
|
|
37
|
+
* they match the lexical aliases ∪ canonical (symlink-resolved) set the gates
|
|
38
|
+
* do (#503); non-path surfaces stay on the `tool` intent.
|
|
20
39
|
*/
|
|
21
40
|
export class LocalPermissionsService implements PermissionsService {
|
|
22
41
|
constructor(
|
|
23
|
-
private readonly
|
|
24
|
-
private readonly
|
|
42
|
+
private readonly resolver: ResolverForService,
|
|
43
|
+
private readonly session: PathNormalizerProvider,
|
|
25
44
|
private readonly formatterRegistry: ToolInputFormatterRegistrar,
|
|
26
45
|
private readonly accessExtractorRegistry: ToolAccessExtractorRegistrar,
|
|
27
46
|
) {}
|
|
@@ -31,18 +50,20 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
31
50
|
value?: string,
|
|
32
51
|
agentName?: string,
|
|
33
52
|
): ReturnType<PermissionsService["checkPermission"]> {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
53
|
+
const intent = buildAccessIntentForSurface(
|
|
54
|
+
surface,
|
|
55
|
+
value,
|
|
56
|
+
this.session.getPathNormalizer(),
|
|
57
|
+
agentName,
|
|
38
58
|
);
|
|
59
|
+
return this.resolver.resolve(intent);
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
getToolPermission(
|
|
42
63
|
toolName: string,
|
|
43
64
|
agentName?: string,
|
|
44
65
|
): ReturnType<PermissionsService["getToolPermission"]> {
|
|
45
|
-
return this.
|
|
66
|
+
return this.resolver.getToolPermission(toolName, agentName);
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
registerToolInputFormatter(
|
|
@@ -375,7 +375,7 @@ describe("BashProgram", () => {
|
|
|
375
375
|
const symlinkCwd = "/private/tmp";
|
|
376
376
|
realpathSync.mockImplementation((p: string) => {
|
|
377
377
|
if (p === "/tmp") return "/private/tmp";
|
|
378
|
-
if (p.startsWith("/tmp/")) return
|
|
378
|
+
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
|
|
379
379
|
return p;
|
|
380
380
|
});
|
|
381
381
|
const program = await BashProgram.parse(
|