@gotgenes/pi-permission-system 20.3.0 → 20.4.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 +15 -0
- package/docs/cross-extension-api.md +4 -0
- package/docs/subagent-integration.md +2 -2
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +7 -5
- package/src/access-intent/bash/bash-path-resolver.ts +1 -2
- package/src/access-intent/bash/msys-bash-tokens.ts +2 -2
- package/src/access-intent/bash/parser.ts +39 -0
- package/src/access-intent/bash/sync-commands.ts +31 -0
- package/src/access-intent/bash/token-classification.ts +18 -32
- package/src/access-intent/path-normalization.ts +24 -43
- package/src/access-intent/tool-kind.ts +57 -0
- package/src/authority/approval-escalator.ts +3 -3
- package/src/authority/authorizer-selection.ts +1 -1
- package/src/authority/authorizer.ts +2 -2
- package/src/authority/denying-authorizer.ts +1 -1
- package/src/authority/forwarded-request-server.ts +3 -3
- package/src/authority/forwarder-context.ts +1 -1
- package/src/authority/forwarding-io.ts +3 -3
- package/src/{forwarding-manager.ts → authority/forwarding-manager.ts} +2 -2
- package/src/authority/local-user-authorizer.ts +2 -2
- package/src/{permission-forwarding.ts → authority/permission-forwarding.ts} +1 -2
- package/src/authority/permission-prompter.ts +2 -2
- package/src/authority/subagent-context.ts +11 -14
- package/src/authority/subagent-detection.ts +5 -4
- package/src/bash-advisory-check.ts +38 -0
- package/src/denial-messages.ts +6 -9
- package/src/handlers/before-agent-start.ts +8 -0
- package/src/handlers/gates/helpers.ts +12 -4
- package/src/handlers/gates/runner.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +3 -2
- package/src/handlers/gates/tool.ts +9 -4
- package/src/index.ts +23 -12
- package/src/input-normalizer.ts +53 -48
- package/src/{canonicalize-path.ts → path/canonicalize-path.ts} +4 -3
- package/src/path/path-containment.ts +24 -0
- package/src/path/path-flavor.ts +114 -0
- package/src/{pi-infrastructure-read.ts → path/pi-infrastructure-read.ts} +12 -17
- package/src/path-normalizer.ts +35 -59
- package/src/permission-gate.ts +1 -1
- package/src/permission-manager.ts +36 -56
- package/src/permission-prompts.ts +4 -3
- package/src/permission-session.ts +6 -5
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +19 -21
- package/src/session-approval.ts +1 -1
- package/src/tool-input-path.ts +17 -19
- package/src/tool-preview-formatter.ts +2 -5
- package/src/path-containment.ts +0 -56
- /package/src/{permission-dialog.ts → authority/permission-dialog.ts} +0 -0
- /package/src/{subagent-lifecycle-events.ts → authority/subagent-lifecycle-events.ts} +0 -0
- /package/src/{subagent-registry.ts → authority/subagent-registry.ts} +0 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
1
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
|
|
2
|
+
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
3
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Narrow context for subagent detection — the only session-manager readers
|
|
@@ -17,17 +16,15 @@ export interface SubagentDetectionContext {
|
|
|
17
16
|
|
|
18
17
|
export function normalizeFilesystemPath(
|
|
19
18
|
pathValue: string,
|
|
20
|
-
|
|
19
|
+
flavor: PathFlavor,
|
|
21
20
|
): string {
|
|
22
|
-
|
|
23
|
-
const normalizedPath = impl.normalize(pathValue);
|
|
24
|
-
return platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
|
|
21
|
+
return flavor.fold(flavor.impl.normalize(pathValue));
|
|
25
22
|
}
|
|
26
23
|
|
|
27
24
|
function isPathWithinDirectoryForSubagent(
|
|
28
25
|
pathValue: string,
|
|
29
26
|
directory: string,
|
|
30
|
-
|
|
27
|
+
flavor: PathFlavor,
|
|
31
28
|
): boolean {
|
|
32
29
|
if (!pathValue || !directory) {
|
|
33
30
|
return false;
|
|
@@ -37,7 +34,7 @@ function isPathWithinDirectoryForSubagent(
|
|
|
37
34
|
return true;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
const sep =
|
|
37
|
+
const sep = flavor.impl.sep;
|
|
41
38
|
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
42
39
|
return pathValue.startsWith(prefix);
|
|
43
40
|
}
|
|
@@ -71,7 +68,7 @@ export function isRegisteredSubagentChild(
|
|
|
71
68
|
export function isSubagentExecutionContext(
|
|
72
69
|
ctx: SubagentDetectionContext,
|
|
73
70
|
subagentSessionsDir: string,
|
|
74
|
-
|
|
71
|
+
flavor: PathFlavor,
|
|
75
72
|
registry?: SubagentSessionRegistry,
|
|
76
73
|
): boolean {
|
|
77
74
|
// 1. Explicit registry — in-process subagent extensions register by child
|
|
@@ -99,14 +96,14 @@ export function isSubagentExecutionContext(
|
|
|
99
96
|
return false;
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
const normalizedSessionDir = normalizeFilesystemPath(sessionDir,
|
|
99
|
+
const normalizedSessionDir = normalizeFilesystemPath(sessionDir, flavor);
|
|
103
100
|
const normalizedSubagentRoot = normalizeFilesystemPath(
|
|
104
101
|
subagentSessionsDir,
|
|
105
|
-
|
|
102
|
+
flavor,
|
|
106
103
|
);
|
|
107
104
|
return isPathWithinDirectoryForSubagent(
|
|
108
105
|
normalizedSessionDir,
|
|
109
106
|
normalizedSubagentRoot,
|
|
110
|
-
|
|
107
|
+
flavor,
|
|
111
108
|
);
|
|
112
109
|
}
|
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
isSubagentExecutionContext,
|
|
4
4
|
type SubagentDetectionContext,
|
|
5
5
|
} from "#src/authority/subagent-context";
|
|
6
|
-
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
6
|
+
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
7
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Narrow seam for the ask-path consumers: "is the current session a subagent?"
|
|
@@ -30,7 +31,7 @@ export interface RegisteredChildDetector {
|
|
|
30
31
|
/** Composition-root inputs for {@link SubagentDetection}. */
|
|
31
32
|
export interface SubagentDetectionDeps {
|
|
32
33
|
subagentSessionsDir: string;
|
|
33
|
-
|
|
34
|
+
flavor: PathFlavor;
|
|
34
35
|
registry?: SubagentSessionRegistry;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -38,7 +39,7 @@ export interface SubagentDetectionDeps {
|
|
|
38
39
|
* Single owner of subagent detection.
|
|
39
40
|
*
|
|
40
41
|
* Constructed once in the composition root with the detection inputs
|
|
41
|
-
* (`subagentSessionsDir`, `
|
|
42
|
+
* (`subagentSessionsDir`, `flavor`, `registry`) and shared across every
|
|
42
43
|
* consumer, replacing the dep triple those consumers previously threaded
|
|
43
44
|
* individually. Delegates to the pure detection functions in
|
|
44
45
|
* {@link ./subagent-context}, holding only the deps.
|
|
@@ -52,7 +53,7 @@ export class SubagentDetection
|
|
|
52
53
|
return isSubagentExecutionContext(
|
|
53
54
|
ctx,
|
|
54
55
|
this.deps.subagentSessionsDir,
|
|
55
|
-
this.deps.
|
|
56
|
+
this.deps.flavor,
|
|
56
57
|
this.deps.registry,
|
|
57
58
|
);
|
|
58
59
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
|
|
2
|
+
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
3
|
+
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an advisory bash query at the gate's decomposed fidelity.
|
|
8
|
+
*
|
|
9
|
+
* When the tree-sitter parser is warm, the command is decomposed into its
|
|
10
|
+
* command-pattern units and routed through the same shared orchestrator the
|
|
11
|
+
* enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
|
|
12
|
+
* command returns the most-restrictive decision (`deny > ask > allow`) and
|
|
13
|
+
* inherits the opaque-wrapper floor (#481) and the fail-closed
|
|
14
|
+
* `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
|
|
15
|
+
*
|
|
16
|
+
* In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
|
|
17
|
+
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
|
18
|
+
* than before — only strengthened once warm.
|
|
19
|
+
*
|
|
20
|
+
* Synchronous, preserving `PermissionsService.checkPermission`'s sync contract:
|
|
21
|
+
* the only async step (parser init) happens earlier, at `before_agent_start`.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveBashAdvisoryCheck(
|
|
24
|
+
command: string,
|
|
25
|
+
agentName: string | undefined,
|
|
26
|
+
resolver: ScopedPermissionResolver,
|
|
27
|
+
): PermissionCheckResult {
|
|
28
|
+
const commands = parseBashCommandsSync(command);
|
|
29
|
+
if (commands === null) {
|
|
30
|
+
return resolver.resolve({
|
|
31
|
+
kind: "tool",
|
|
32
|
+
surface: "bash",
|
|
33
|
+
input: { command },
|
|
34
|
+
agentName,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return resolveBashCommandCheck(command, commands, agentName, resolver);
|
|
38
|
+
}
|
package/src/denial-messages.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
1
2
|
import { EXTENSION_ID } from "./extension-config";
|
|
2
3
|
import type { BashCommandContext, PermissionCheckResult } from "./types";
|
|
3
4
|
|
|
@@ -127,7 +128,7 @@ function buildToolDenyBody(
|
|
|
127
128
|
parts.push(`Agent '${agentName}'`);
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
if (isMcpCheck(check)) {
|
|
131
|
+
if (isMcpCheck(check) && check.target) {
|
|
131
132
|
parts.push(`is not permitted to run MCP target '${check.target}'`);
|
|
132
133
|
} else {
|
|
133
134
|
parts.push(`is not permitted to run '${check.toolName}'`);
|
|
@@ -190,10 +191,10 @@ function buildUnavailableBody(ctx: DenialContext): string {
|
|
|
190
191
|
switch (ctx.kind) {
|
|
191
192
|
case "tool": {
|
|
192
193
|
const { check } = ctx;
|
|
193
|
-
if (check.toolName === "bash" && check.command) {
|
|
194
|
+
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
194
195
|
return `Running bash command '${check.command}' requires approval, but no interactive UI is available.`;
|
|
195
196
|
}
|
|
196
|
-
if (isMcpCheck(check)) {
|
|
197
|
+
if (isMcpCheck(check) && check.target) {
|
|
197
198
|
return "Using tool 'mcp' requires approval, but no interactive UI is available.";
|
|
198
199
|
}
|
|
199
200
|
return `Using tool '${check.toolName}' requires approval, but no interactive UI is available.`;
|
|
@@ -220,10 +221,10 @@ function buildUserDeniedBody(
|
|
|
220
221
|
switch (ctx.kind) {
|
|
221
222
|
case "tool": {
|
|
222
223
|
const { check } = ctx;
|
|
223
|
-
if (isMcpCheck(check)) {
|
|
224
|
+
if (isMcpCheck(check) && check.target) {
|
|
224
225
|
return `User denied MCP target '${check.target}'.${reasonSuffix(denialReason)}`;
|
|
225
226
|
}
|
|
226
|
-
if (check.toolName === "bash" && check.command) {
|
|
227
|
+
if (classifyToolKind(check.toolName) === "bash" && check.command) {
|
|
227
228
|
return `User denied bash command '${check.command}'.${reasonSuffix(denialReason)}`;
|
|
228
229
|
}
|
|
229
230
|
return `User denied tool '${check.toolName}'.${reasonSuffix(denialReason)}`;
|
|
@@ -243,10 +244,6 @@ function buildUserDeniedBody(
|
|
|
243
244
|
}
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
function isMcpCheck(check: PermissionCheckResult): boolean {
|
|
247
|
-
return (check.source === "mcp" || check.toolName === "mcp") && !!check.target;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
247
|
/** Render an external-path disclosure list for the bash deny body's path clause. */
|
|
251
248
|
function formatExternalPathList(paths: ExternalPathDisclosure[]): string {
|
|
252
249
|
return paths
|
|
@@ -40,12 +40,16 @@ export function shouldExposeTool(
|
|
|
40
40
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
41
41
|
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
|
|
42
42
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
43
|
+
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
|
|
44
|
+
* bash path can decompose at gate parity; `before_agent_start` precedes any
|
|
45
|
+
* tool call, so triggering it here closes the pre-warm window (#309)
|
|
43
46
|
*/
|
|
44
47
|
export class AgentPrepHandler {
|
|
45
48
|
constructor(
|
|
46
49
|
private readonly session: PermissionSession,
|
|
47
50
|
private readonly resolver: PermissionResolver,
|
|
48
51
|
private readonly toolRegistry: ToolRegistry,
|
|
52
|
+
private readonly warmParser: () => void,
|
|
49
53
|
) {}
|
|
50
54
|
|
|
51
55
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
@@ -53,6 +57,10 @@ export class AgentPrepHandler {
|
|
|
53
57
|
event: BeforeAgentStartPayload,
|
|
54
58
|
ctx: ExtensionContext,
|
|
55
59
|
): Promise<BeforeAgentStartEventResult> {
|
|
60
|
+
// Fire-and-forget: warming is idempotent and best-effort, so it never
|
|
61
|
+
// delays agent start. A bash advisory query before it completes falls back
|
|
62
|
+
// to whole-string matching.
|
|
63
|
+
this.warmParser();
|
|
56
64
|
this.session.activate(ctx);
|
|
57
65
|
this.session.refreshConfig(ctx);
|
|
58
66
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
1
2
|
import type {
|
|
2
3
|
PermissionDecisionEvent,
|
|
3
4
|
PermissionDecisionResolution,
|
|
@@ -14,10 +15,17 @@ export function deriveDecisionValue(
|
|
|
14
15
|
check: Pick<PermissionCheckResult, "command" | "target">,
|
|
15
16
|
path?: string,
|
|
16
17
|
): string {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
switch (classifyToolKind(toolName)) {
|
|
19
|
+
case "bash":
|
|
20
|
+
return check.command ?? toolName;
|
|
21
|
+
case "mcp":
|
|
22
|
+
return check.target ?? toolName;
|
|
23
|
+
case "path":
|
|
24
|
+
case "skill":
|
|
25
|
+
case "extension":
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty path falls through to toolName (the original `if (path)` truthiness)
|
|
27
|
+
return path || toolName;
|
|
28
|
+
}
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AskEscalator } from "#src/authority/authorizer-selection";
|
|
2
|
+
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
3
4
|
import {
|
|
4
5
|
formatDenyReason,
|
|
5
6
|
formatUnavailableReason,
|
|
6
7
|
formatUserDeniedReason,
|
|
7
8
|
} from "#src/denial-messages";
|
|
8
|
-
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
9
9
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
10
10
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
11
11
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
3
4
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
4
5
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
5
6
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
@@ -77,7 +78,7 @@ export class ToolCallGatePipeline {
|
|
|
77
78
|
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
78
79
|
const normalizer = this.inputs.getPathNormalizer();
|
|
79
80
|
const bashProgram =
|
|
80
|
-
tcc.toolName === "bash" && command
|
|
81
|
+
classifyToolKind(tcc.toolName) === "bash" && command
|
|
81
82
|
? await BashProgram.parse(
|
|
82
83
|
command,
|
|
83
84
|
normalizer,
|
|
@@ -159,7 +160,7 @@ export class ToolCallGatePipeline {
|
|
|
159
160
|
command: string | null,
|
|
160
161
|
normalizer: PathNormalizer,
|
|
161
162
|
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
|
162
|
-
if (tcc.toolName === "bash" && bashProgram) {
|
|
163
|
+
if (classifyToolKind(tcc.toolName) === "bash" && bashProgram) {
|
|
163
164
|
return {
|
|
164
165
|
toolCheck: resolveBashCommandCheck(
|
|
165
166
|
command ?? "",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
2
3
|
import { PATH_BEARING_TOOLS } from "#src/path-surfaces";
|
|
3
4
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
4
5
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
@@ -23,10 +24,14 @@ function deriveSuggestionValue(
|
|
|
23
24
|
check: PermissionCheckResult,
|
|
24
25
|
accessPath?: AccessPath,
|
|
25
26
|
): string {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
switch (classifyToolKind(tcc.toolName)) {
|
|
28
|
+
case "bash":
|
|
29
|
+
return check.command ?? "";
|
|
30
|
+
case "mcp":
|
|
31
|
+
return check.target ?? "mcp";
|
|
32
|
+
default:
|
|
33
|
+
return accessPath ? accessPath.value() : "*";
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/**
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { warmBashParser } from "./access-intent/bash/parser";
|
|
3
4
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
4
5
|
import {
|
|
5
6
|
ForwardedRequestServer,
|
|
6
7
|
type ServingPolicy,
|
|
7
8
|
} from "./authority/forwarded-request-server";
|
|
9
|
+
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
10
|
+
import { requestPermissionDecisionFromUi } from "./authority/permission-dialog";
|
|
8
11
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
9
12
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
13
|
+
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
14
|
+
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
|
10
15
|
import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
|
|
11
16
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
12
17
|
import { getGlobalConfigPath } from "./config-paths";
|
|
@@ -15,7 +20,6 @@ import { DecisionAudit } from "./decision-audit";
|
|
|
15
20
|
import { GateDecisionReporter } from "./decision-reporter";
|
|
16
21
|
import { isYoloModeEnabled } from "./extension-config";
|
|
17
22
|
import { computeExtensionPaths } from "./extension-paths";
|
|
18
|
-
import { ForwardingManager } from "./forwarding-manager";
|
|
19
23
|
import {
|
|
20
24
|
AgentPrepHandler,
|
|
21
25
|
PermissionGateHandler,
|
|
@@ -26,7 +30,7 @@ import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeli
|
|
|
26
30
|
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
|
27
31
|
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
|
|
28
32
|
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
29
|
-
import {
|
|
33
|
+
import { pathFlavorForPlatform } from "./path/path-flavor";
|
|
30
34
|
import { PermissionManager } from "./permission-manager";
|
|
31
35
|
import { PermissionResolver } from "./permission-resolver";
|
|
32
36
|
import { PermissionSession } from "./permission-session";
|
|
@@ -34,8 +38,6 @@ import { LocalPermissionsService } from "./permissions-service";
|
|
|
34
38
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
35
39
|
import { PermissionSessionLogger } from "./session-logger";
|
|
36
40
|
import { SessionRules } from "./session-rules";
|
|
37
|
-
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
38
|
-
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
39
41
|
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
|
|
40
42
|
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
41
43
|
|
|
@@ -44,17 +46,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
44
46
|
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools
|
|
45
47
|
// so the agent can read Pi's bundled docs/examples regardless of layout.
|
|
46
48
|
const paths = computeExtensionPaths(agentDir, getPackageDir());
|
|
47
|
-
// The single process.platform read for the whole extension
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
49
|
+
// The single process.platform read for the whole extension, resolved once
|
|
50
|
+
// into the path-language flavor that every consumer shares (the session's
|
|
51
|
+
// PathNormalizer, rule evaluation, and subagent detection). Interior modules
|
|
52
|
+
// must not read process.platform (enforced by the eslint guard scoped to
|
|
53
|
+
// src/) and never re-derive the win32 flavor — they receive this product.
|
|
54
|
+
const hostFlavor = pathFlavorForPlatform(process.platform);
|
|
51
55
|
const sessionRules = new SessionRules();
|
|
52
56
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
53
57
|
// Single owner of subagent detection, shared across every consumer instead of
|
|
54
58
|
// threading the (subagentSessionsDir, platform, registry) triple into each.
|
|
55
59
|
const subagentDetection = new SubagentDetection({
|
|
56
60
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
57
|
-
|
|
61
|
+
flavor: hostFlavor,
|
|
58
62
|
registry: subagentRegistry,
|
|
59
63
|
});
|
|
60
64
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
@@ -75,7 +79,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
75
79
|
// assigned below. yolo becomes a composition-stage ask→allow rewrite (#526).
|
|
76
80
|
const permissionManager = new PermissionManager({
|
|
77
81
|
agentDir,
|
|
78
|
-
|
|
82
|
+
flavor: hostFlavor,
|
|
79
83
|
isYoloEnabled: () => isYoloModeEnabled(configStore.current()),
|
|
80
84
|
});
|
|
81
85
|
|
|
@@ -146,7 +150,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
146
150
|
sessionRules,
|
|
147
151
|
configStore,
|
|
148
152
|
authorizerSelection,
|
|
149
|
-
|
|
153
|
+
hostFlavor,
|
|
150
154
|
);
|
|
151
155
|
|
|
152
156
|
// refresh() must run after `session` is assigned: a debug-write IO failure
|
|
@@ -204,7 +208,14 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
204
208
|
logger,
|
|
205
209
|
audit,
|
|
206
210
|
);
|
|
207
|
-
const agentPrep = new AgentPrepHandler(
|
|
211
|
+
const agentPrep = new AgentPrepHandler(
|
|
212
|
+
session,
|
|
213
|
+
resolver,
|
|
214
|
+
toolRegistry,
|
|
215
|
+
() => {
|
|
216
|
+
void warmBashParser();
|
|
217
|
+
},
|
|
218
|
+
);
|
|
208
219
|
|
|
209
220
|
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
210
221
|
const gateRunner = new GateRunner(
|
package/src/input-normalizer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
2
3
|
import { stripBashCommentLines } from "./bash-arity";
|
|
3
4
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
4
5
|
import type { PathNormalizer } from "./path-normalizer";
|
|
@@ -106,55 +107,59 @@ export function normalizeInput(
|
|
|
106
107
|
input: unknown,
|
|
107
108
|
configuredMcpServerNames: readonly string[],
|
|
108
109
|
): NormalizedInput {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
switch (classifyToolKind(toolName)) {
|
|
111
|
+
// --- Skill ---
|
|
112
|
+
case "skill": {
|
|
113
|
+
const record = toRecord(input);
|
|
114
|
+
const skillName = record.name;
|
|
115
|
+
const lookupValue = typeof skillName === "string" ? skillName : "*";
|
|
116
|
+
return {
|
|
117
|
+
surface: "skill",
|
|
118
|
+
values: [lookupValue],
|
|
119
|
+
resultExtras: {},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
123
|
+
// --- Bash ---
|
|
124
|
+
case "bash": {
|
|
125
|
+
const record = toRecord(input);
|
|
126
|
+
const command = typeof record.command === "string" ? record.command : "";
|
|
127
|
+
// Strip leading shell comment lines so pattern matching operates on the
|
|
128
|
+
// actual command, not a `# description` prefix agents often prepend.
|
|
129
|
+
// Fall back to the raw command when stripping leaves nothing, so an
|
|
130
|
+
// all-comment command still evaluates against its literal text.
|
|
131
|
+
const matchValue = stripBashCommentLines(command) || command;
|
|
132
|
+
return {
|
|
133
|
+
surface: "bash",
|
|
134
|
+
values: [matchValue],
|
|
135
|
+
resultExtras: { command },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
// --- MCP ---
|
|
140
|
+
case "mcp": {
|
|
141
|
+
const mcpTargets = [
|
|
142
|
+
...createMcpPermissionTargets(input, configuredMcpServerNames),
|
|
143
|
+
"mcp",
|
|
144
|
+
];
|
|
145
|
+
const fallbackTarget = mcpTargets[0] ?? "mcp";
|
|
146
|
+
return {
|
|
147
|
+
surface: "mcp",
|
|
148
|
+
values: mcpTargets,
|
|
149
|
+
resultExtras: { target: fallbackTarget },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
// --- All other surfaces (path-bearing tools and extension tools) ---
|
|
154
|
+
// Path-bearing tools with a present path never reach here — the gate emits
|
|
155
|
+
// an access-path intent (#502). Missing-path and extension-tool cases both
|
|
156
|
+
// collapse to the surface catch-all.
|
|
157
|
+
case "path":
|
|
158
|
+
case "extension":
|
|
159
|
+
return {
|
|
160
|
+
surface: toolName,
|
|
161
|
+
values: ["*"],
|
|
162
|
+
resultExtras: {},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
160
165
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Resolve symlinks in an absolute path, best-effort.
|
|
@@ -13,11 +14,11 @@ import { posix as posixPath, win32 as winPath } from "node:path";
|
|
|
13
14
|
*/
|
|
14
15
|
export function canonicalizePath(
|
|
15
16
|
absolutePath: string,
|
|
16
|
-
|
|
17
|
+
flavor: PathFlavor,
|
|
17
18
|
): string {
|
|
18
19
|
if (!absolutePath) return absolutePath;
|
|
19
20
|
|
|
20
|
-
const impl =
|
|
21
|
+
const { impl } = flavor;
|
|
21
22
|
const root = impl.parse(absolutePath).root;
|
|
22
23
|
const rest = absolutePath.slice(root.length);
|
|
23
24
|
const parts = rest.split(impl.sep).filter(Boolean);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
|
+
import { isSafeSystemPath } from "#src/safe-system-paths";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure geometry: is `canonicalPath` outside `canonicalCwd`?
|
|
6
|
+
*
|
|
7
|
+
* Both operands must already be canonical (symlink-resolved, win32-lowercased)
|
|
8
|
+
* — the caller prepares them (see {@link PathNormalizer.isOutsideWorkingDirectory}).
|
|
9
|
+
* This predicate touches no filesystem and does no derivation; the containment
|
|
10
|
+
* geometry lives on {@link PathFlavor.isWithin}.
|
|
11
|
+
*/
|
|
12
|
+
export function isPathOutsideWorkingDirectory(
|
|
13
|
+
canonicalPath: string,
|
|
14
|
+
canonicalCwd: string,
|
|
15
|
+
flavor: PathFlavor,
|
|
16
|
+
): boolean {
|
|
17
|
+
if (!canonicalCwd || !canonicalPath) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (isSafeSystemPath(canonicalPath)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return !flavor.isWithin(canonicalPath, canonicalCwd);
|
|
24
|
+
}
|