@gotgenes/pi-permission-system 5.2.1 → 5.3.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 +21 -0
- package/README.md +166 -0
- package/package.json +1 -1
- package/src/handlers/input.ts +31 -4
- package/src/handlers/lifecycle.ts +1 -0
- package/src/handlers/tool-call.ts +135 -9
- package/src/handlers/types.ts +5 -0
- package/src/index.ts +17 -0
- package/src/permission-dialog.ts +6 -0
- package/src/permission-event-rpc.ts +229 -0
- package/src/permission-events.ts +159 -0
- package/src/permission-prompter.ts +1 -1
- package/tests/handlers/before-agent-start.test.ts +2 -0
- package/tests/handlers/input-events.test.ts +226 -0
- package/tests/handlers/input.test.ts +2 -0
- package/tests/handlers/lifecycle.test.ts +8 -0
- package/tests/handlers/tool-call-events.test.ts +389 -0
- package/tests/handlers/tool-call.test.ts +2 -0
- package/tests/permission-event-rpc.test.ts +499 -0
- package/tests/permission-events.test.ts +299 -0
- package/tests/permission-prompter.test.ts +5 -1
- package/tests/permission-system.test.ts +1 -0
- package/tests/session-start.test.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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
|
+
## [5.3.0](https://github.com/gotgenes/pi-permission-system/compare/v5.2.1...v5.3.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add permission event types and emit helpers ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([45a4158](https://github.com/gotgenes/pi-permission-system/commit/45a415833818b78651671f6a33103e874cc137be))
|
|
14
|
+
* add permissions:rpc:check policy query RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([b230ff8](https://github.com/gotgenes/pi-permission-system/commit/b230ff8078679d85a63f14888f3feb36054201ff))
|
|
15
|
+
* add permissions:rpc:prompt forwarding RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([438227c](https://github.com/gotgenes/pi-permission-system/commit/438227c090d620c948cf91d26d8cf0b85ec9b66e))
|
|
16
|
+
* clean up RPC handlers on session shutdown ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([0a54a10](https://github.com/gotgenes/pi-permission-system/commit/0a54a108a824168bfab7059c3d78bd182dbbaccd))
|
|
17
|
+
* distinguish auto-approved from user-approved in decision events ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([746d988](https://github.com/gotgenes/pi-permission-system/commit/746d988fc0af4dd06e507b79069bf199b1c7dfd7))
|
|
18
|
+
* emit permission decision events from input handler ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([bfc21bb](https://github.com/gotgenes/pi-permission-system/commit/bfc21bba8c81aff58655df8928c43c4e68e9a2af))
|
|
19
|
+
* emit permission decision events from tool-call handler ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([40cf12e](https://github.com/gotgenes/pi-permission-system/commit/40cf12e78fcd52a37f7da46d75fb1c1b2a26d15e))
|
|
20
|
+
* emit permissions:ready on extension load ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([bfa3606](https://github.com/gotgenes/pi-permission-system/commit/bfa360633b25048e7f435141700dbbecaf77274c))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* document permission event API and RPC protocol ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([3872e54](https://github.com/gotgenes/pi-permission-system/commit/3872e5416bfb118b4ab5538a122ac3f9bccf40d7))
|
|
26
|
+
* plan permission event channel with decision broadcast and RPC ([#29](https://github.com/gotgenes/pi-permission-system/issues/29)) ([d31754a](https://github.com/gotgenes/pi-permission-system/commit/d31754ad90c02c11f6b92fcc3c33f8dbf76ccee3))
|
|
27
|
+
* **retro:** add retro notes for issue [#97](https://github.com/gotgenes/pi-permission-system/issues/97) ([43d66d9](https://github.com/gotgenes/pi-permission-system/commit/43d66d932fc4ed121a6789b241112c1bd6c777f0))
|
|
28
|
+
|
|
8
29
|
## [5.2.1](https://github.com/gotgenes/pi-permission-system/compare/v5.2.0...v5.2.1) (2026-05-05)
|
|
9
30
|
|
|
10
31
|
|
package/README.md
CHANGED
|
@@ -687,6 +687,172 @@ npx --yes ajv-cli@5 validate \
|
|
|
687
687
|
|
|
688
688
|
---
|
|
689
689
|
|
|
690
|
+
## Event API
|
|
691
|
+
|
|
692
|
+
The extension emits events on Pi's `pi.events` bus so other extensions can observe permission decisions and integrate with the policy system without importing this package.
|
|
693
|
+
|
|
694
|
+
### Stability guarantee
|
|
695
|
+
|
|
696
|
+
Fields may be added to any payload, but existing fields will not be removed or renamed without a semver-major version bump.
|
|
697
|
+
The protocol version constant is exported from `src/permission-events.ts` and embedded in every RPC reply.
|
|
698
|
+
|
|
699
|
+
### Channel reference
|
|
700
|
+
|
|
701
|
+
|Channel|Direction|When|Payload type|
|
|
702
|
+
|---|---|---|---|
|
|
703
|
+
|`permissions:ready`|Broadcast|Once, immediately after load|`PermissionsReadyEvent`|
|
|
704
|
+
|`permissions:decision`|Broadcast|After every gate resolution|`PermissionDecisionEvent`|
|
|
705
|
+
|`permissions:rpc:check`|Request|On-demand|`PermissionsCheckRequest`|
|
|
706
|
+
|`permissions:rpc:check:reply:<requestId>`|Reply|After each check request|`PermissionsRpcReply<PermissionsCheckReplyData>`|
|
|
707
|
+
|`permissions:rpc:prompt`|Request|On-demand|`PermissionsPromptRequest`|
|
|
708
|
+
|`permissions:rpc:prompt:reply:<requestId>`|Reply|After prompt is resolved|`PermissionsRpcReply<PermissionsPromptReplyData>`|
|
|
709
|
+
|
|
710
|
+
### Surface 1 — Decision broadcasts
|
|
711
|
+
|
|
712
|
+
Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
|
|
713
|
+
This is useful for dashboards, telemetry, or audit overlays.
|
|
714
|
+
|
|
715
|
+
```typescript
|
|
716
|
+
pi.events.on("permissions:decision", (raw) => {
|
|
717
|
+
const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
|
|
718
|
+
console.log(event.surface, event.result, event.resolution);
|
|
719
|
+
// e.g. "bash" "allow" "user_approved_for_session"
|
|
720
|
+
});
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Payload fields:
|
|
724
|
+
|
|
725
|
+
|Field|Type|Description|
|
|
726
|
+
|---|---|---|
|
|
727
|
+
|`surface`|`string`|Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.)|
|
|
728
|
+
|`value`|`string`|Value evaluated (command, tool name, skill name, path)|
|
|
729
|
+
|`result`|`"allow" \| "deny"`|Final outcome|
|
|
730
|
+
|`resolution`|`string`|How the outcome was reached (see table below)|
|
|
731
|
+
|`origin`|`string \| null`|Config scope that contributed the winning rule|
|
|
732
|
+
|`agentName`|`string \| null`|Active agent name when known|
|
|
733
|
+
|`matchedPattern`|`string \| null`|Pattern from the winning rule|
|
|
734
|
+
|
|
735
|
+
Resolution values:
|
|
736
|
+
|
|
737
|
+
|Value|Meaning|
|
|
738
|
+
|---|---|
|
|
739
|
+
|`policy_allow`|Config rule said allow — no prompt shown|
|
|
740
|
+
|`policy_deny`|Config rule said deny — blocked immediately|
|
|
741
|
+
|`session_approved`|Covered by a session-level approval from earlier in the same session|
|
|
742
|
+
|`infrastructure_auto_allowed`|Read of a Pi infrastructure path — auto-allowed|
|
|
743
|
+
|`user_approved`|User approved once via dialog|
|
|
744
|
+
|`user_approved_for_session`|User approved for the rest of the session|
|
|
745
|
+
|`user_denied`|User denied via dialog|
|
|
746
|
+
|`auto_approved`|Yolo mode — approved automatically without dialog|
|
|
747
|
+
|`confirmation_unavailable`|State was `ask` but no UI was available — blocked|
|
|
748
|
+
|
|
749
|
+
### Surface 2 — Policy query RPC
|
|
750
|
+
|
|
751
|
+
Other extensions can evaluate the current permission policy without importing this package.
|
|
752
|
+
The call is synchronous-style: emit a request, listen on a scoped reply channel.
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
const requestId = crypto.randomUUID();
|
|
756
|
+
|
|
757
|
+
// Listen for the reply first
|
|
758
|
+
const unsub = pi.events.on(
|
|
759
|
+
`permissions:rpc:check:reply:${requestId}`,
|
|
760
|
+
(raw) => {
|
|
761
|
+
unsub();
|
|
762
|
+
const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
|
|
763
|
+
import("@gotgenes/pi-permission-system").PermissionsCheckReplyData
|
|
764
|
+
>;
|
|
765
|
+
if (reply.success) {
|
|
766
|
+
console.log(reply.data?.result); // "allow" | "deny" | "ask"
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
// Then emit the request
|
|
772
|
+
pi.events.emit("permissions:rpc:check", {
|
|
773
|
+
requestId,
|
|
774
|
+
surface: "bash",
|
|
775
|
+
value: "git push",
|
|
776
|
+
agentName: "Worker", // optional
|
|
777
|
+
});
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
If the extension is not loaded, no reply arrives.
|
|
781
|
+
Callers should implement a timeout and treat no-reply as `deny` (graceful degradation).
|
|
782
|
+
|
|
783
|
+
Request fields:
|
|
784
|
+
|
|
785
|
+
|Field|Required|Description|
|
|
786
|
+
|---|---|---|
|
|
787
|
+
|`requestId`|Yes|Unique string; scopes the reply channel|
|
|
788
|
+
|`surface`|Yes|Permission surface to evaluate|
|
|
789
|
+
|`value`|No|Value to evaluate (command, name, path); defaults to `"*"`|
|
|
790
|
+
|`agentName`|No|Agent name for per-agent policy resolution|
|
|
791
|
+
|
|
792
|
+
Reply data fields (`PermissionsCheckReplyData`):
|
|
793
|
+
|
|
794
|
+
|Field|Type|Description|
|
|
795
|
+
|---|---|---|
|
|
796
|
+
|`result`|`"allow" \| "deny" \| "ask"`|Policy decision (including active session rules)|
|
|
797
|
+
|`matchedPattern`|`string \| null`|Matched rule pattern|
|
|
798
|
+
|`origin`|`string \| null`|Config scope of the winning rule|
|
|
799
|
+
|
|
800
|
+
### Surface 3 — Prompt forwarding RPC
|
|
801
|
+
|
|
802
|
+
In-process child sessions (e.g. tintinweb/pi-subagents running via `createAgentSession()`) cannot use file-based permission forwarding because no child process is spawned.
|
|
803
|
+
They can instead forward permission prompts to the parent session's UI via this RPC.
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
const requestId = crypto.randomUUID();
|
|
807
|
+
|
|
808
|
+
const unsub = pi.events.on(
|
|
809
|
+
`permissions:rpc:prompt:reply:${requestId}`,
|
|
810
|
+
(raw) => {
|
|
811
|
+
unsub();
|
|
812
|
+
const reply = raw as import("@gotgenes/pi-permission-system").PermissionsRpcReply<
|
|
813
|
+
import("@gotgenes/pi-permission-system").PermissionsPromptReplyData
|
|
814
|
+
>;
|
|
815
|
+
if (reply.success && reply.data?.approved) {
|
|
816
|
+
// proceed
|
|
817
|
+
} else {
|
|
818
|
+
// deny — either user denied or no UI was available (error: "no_ui")
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
pi.events.emit("permissions:rpc:prompt", {
|
|
824
|
+
requestId,
|
|
825
|
+
surface: "bash",
|
|
826
|
+
value: "rm -rf /tmp/build",
|
|
827
|
+
message: "Allow rm -rf /tmp/build?",
|
|
828
|
+
agentName: "Explore", // optional
|
|
829
|
+
sessionLabel: "Allow rm *", // optional — label for the \"for this session\" option
|
|
830
|
+
});
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
The handler replies with `{ success: false, error: "no_ui" }` when no interactive session is available.
|
|
834
|
+
A successful reply contains:
|
|
835
|
+
|
|
836
|
+
|Field|Type|Description|
|
|
837
|
+
|---|---|---|
|
|
838
|
+
|`approved`|`boolean`|Whether the user approved|
|
|
839
|
+
|`state`|`string`|`"approved"`, `"approved_for_session"`, `"denied"`, or `"denied_with_reason"`|
|
|
840
|
+
|`denialReason`|`string` (optional)|User-provided denial reason|
|
|
841
|
+
|
|
842
|
+
### Ready event
|
|
843
|
+
|
|
844
|
+
The extension emits `permissions:ready` once immediately after it loads.
|
|
845
|
+
Consumers that start after the extension can check via a ping-style RPC check — the `permissions:rpc:check` handler is active as long as the extension is loaded.
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
pi.events.on("permissions:ready", (raw) => {
|
|
849
|
+
const event = raw as import("@gotgenes/pi-permission-system").PermissionsReadyEvent;
|
|
850
|
+
console.log("Permission system loaded, protocol version:", event.protocolVersion);
|
|
851
|
+
});
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
---
|
|
855
|
+
|
|
690
856
|
## Migration from pre-v2 layout
|
|
691
857
|
|
|
692
858
|
Before v2, config was split across two files:
|
package/package.json
CHANGED
package/src/handlers/input.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface InputPayload {
|
|
|
8
8
|
text: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
import { emitDecisionEvent } from "../permission-events";
|
|
11
12
|
import { applyPermissionGate } from "../permission-gate";
|
|
12
13
|
import { formatSkillAskPrompt } from "../permission-prompts";
|
|
13
14
|
import type { HandlerDeps } from "./types";
|
|
@@ -65,17 +66,22 @@ export async function handleInput(
|
|
|
65
66
|
skillName,
|
|
66
67
|
agentName ?? undefined,
|
|
67
68
|
);
|
|
69
|
+
const skillInputCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
70
|
+
let skillInputAutoApproved = false;
|
|
68
71
|
const skillInputGate = await applyPermissionGate({
|
|
69
72
|
state: check.state,
|
|
70
|
-
canConfirm:
|
|
71
|
-
promptForApproval: () =>
|
|
72
|
-
deps.promptPermission(ctx, {
|
|
73
|
+
canConfirm: skillInputCanConfirm,
|
|
74
|
+
promptForApproval: async () => {
|
|
75
|
+
const decision = await deps.promptPermission(ctx, {
|
|
73
76
|
requestId: deps.createPermissionRequestId("skill-input"),
|
|
74
77
|
source: "skill_input",
|
|
75
78
|
agentName,
|
|
76
79
|
message: skillInputMessage,
|
|
77
80
|
skillName,
|
|
78
|
-
})
|
|
81
|
+
});
|
|
82
|
+
skillInputAutoApproved = decision.autoApproved === true;
|
|
83
|
+
return decision;
|
|
84
|
+
},
|
|
79
85
|
writeLog: deps.runtime.writeReviewLog,
|
|
80
86
|
logContext: {
|
|
81
87
|
source: "skill_input",
|
|
@@ -91,6 +97,27 @@ export async function handleInput(
|
|
|
91
97
|
},
|
|
92
98
|
});
|
|
93
99
|
|
|
100
|
+
emitDecisionEvent(deps.events, {
|
|
101
|
+
surface: "skill",
|
|
102
|
+
value: skillName,
|
|
103
|
+
result: skillInputGate.action === "allow" ? "allow" : "deny",
|
|
104
|
+
resolution:
|
|
105
|
+
check.state === "allow"
|
|
106
|
+
? "policy_allow"
|
|
107
|
+
: check.state === "deny"
|
|
108
|
+
? "policy_deny"
|
|
109
|
+
: skillInputGate.action === "allow"
|
|
110
|
+
? skillInputAutoApproved
|
|
111
|
+
? "auto_approved"
|
|
112
|
+
: "user_approved"
|
|
113
|
+
: skillInputCanConfirm
|
|
114
|
+
? "user_denied"
|
|
115
|
+
: "confirmation_unavailable",
|
|
116
|
+
origin: check.origin ?? null,
|
|
117
|
+
agentName: agentName ?? null,
|
|
118
|
+
matchedPattern: check.matchedPattern ?? null,
|
|
119
|
+
});
|
|
120
|
+
|
|
94
121
|
if (skillInputGate.action === "block") {
|
|
95
122
|
return { action: "handled" };
|
|
96
123
|
}
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
} from "../external-directory";
|
|
22
22
|
import { suggestSessionPattern } from "../pattern-suggest";
|
|
23
23
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
24
|
+
import {
|
|
25
|
+
emitDecisionEvent,
|
|
26
|
+
type PermissionDecisionResolution,
|
|
27
|
+
} from "../permission-events";
|
|
24
28
|
import { applyPermissionGate } from "../permission-gate";
|
|
25
29
|
import {
|
|
26
30
|
formatAskPrompt,
|
|
@@ -38,8 +42,50 @@ import {
|
|
|
38
42
|
checkRequestedToolRegistration,
|
|
39
43
|
getToolNameFromValue,
|
|
40
44
|
} from "../tool-registry";
|
|
45
|
+
import type { PermissionCheckResult } from "../types";
|
|
41
46
|
import type { HandlerDeps } from "./types";
|
|
42
47
|
|
|
48
|
+
// ── Emission helper ────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive the human-readable value for a decision event from a check result.
|
|
52
|
+
* Bash → extracted command; MCP → qualified target; others → tool name.
|
|
53
|
+
*/
|
|
54
|
+
function deriveDecisionValue(
|
|
55
|
+
toolName: string,
|
|
56
|
+
check: Pick<PermissionCheckResult, "command" | "target">,
|
|
57
|
+
): string {
|
|
58
|
+
if (toolName === "bash") return check.command ?? toolName;
|
|
59
|
+
if (toolName === "mcp") return check.target ?? toolName;
|
|
60
|
+
return toolName;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Map the gate outcome back to a PermissionDecisionResolution.
|
|
65
|
+
*
|
|
66
|
+
* @param state - The permission state passed to the gate.
|
|
67
|
+
* @param action - The gate's resulting action ("allow" | "block").
|
|
68
|
+
* @param hasSession - True when the gate result carries a sessionApproval
|
|
69
|
+
* (indicates the user chose "for this session").
|
|
70
|
+
* @param canConfirm - Whether an interactive prompt was available.
|
|
71
|
+
*/
|
|
72
|
+
function deriveResolution(
|
|
73
|
+
state: "allow" | "deny" | "ask",
|
|
74
|
+
action: "allow" | "block",
|
|
75
|
+
hasSession: boolean,
|
|
76
|
+
canConfirm: boolean,
|
|
77
|
+
autoApproved = false,
|
|
78
|
+
): PermissionDecisionResolution {
|
|
79
|
+
if (state === "allow") return "policy_allow";
|
|
80
|
+
if (state === "deny") return "policy_deny";
|
|
81
|
+
// state === "ask"
|
|
82
|
+
if (action === "allow") {
|
|
83
|
+
if (autoApproved) return "auto_approved";
|
|
84
|
+
return hasSession ? "user_approved_for_session" : "user_approved";
|
|
85
|
+
}
|
|
86
|
+
return canConfirm ? "user_denied" : "confirmation_unavailable";
|
|
87
|
+
}
|
|
88
|
+
|
|
43
89
|
/**
|
|
44
90
|
* Extract the tool input from an event, checking both `input` and `arguments`
|
|
45
91
|
* fields (different Pi SDK versions use different names).
|
|
@@ -112,9 +158,10 @@ export async function handleToolCall(
|
|
|
112
158
|
readEvent.input.path,
|
|
113
159
|
agentName ?? undefined,
|
|
114
160
|
);
|
|
161
|
+
const skillReadCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
115
162
|
const skillReadGate = await applyPermissionGate({
|
|
116
163
|
state: matchedSkill.state,
|
|
117
|
-
canConfirm:
|
|
164
|
+
canConfirm: skillReadCanConfirm,
|
|
118
165
|
promptForApproval: () =>
|
|
119
166
|
deps.promptPermission(ctx, {
|
|
120
167
|
requestId: (readEvent as { toolCallId: string }).toolCallId,
|
|
@@ -149,6 +196,20 @@ export async function handleToolCall(
|
|
|
149
196
|
},
|
|
150
197
|
},
|
|
151
198
|
});
|
|
199
|
+
emitDecisionEvent(deps.events, {
|
|
200
|
+
surface: "skill",
|
|
201
|
+
value: matchedSkill.name,
|
|
202
|
+
result: skillReadGate.action === "allow" ? "allow" : "deny",
|
|
203
|
+
resolution: deriveResolution(
|
|
204
|
+
matchedSkill.state,
|
|
205
|
+
skillReadGate.action,
|
|
206
|
+
false,
|
|
207
|
+
skillReadCanConfirm,
|
|
208
|
+
),
|
|
209
|
+
origin: null,
|
|
210
|
+
agentName: agentName ?? null,
|
|
211
|
+
matchedPattern: null,
|
|
212
|
+
});
|
|
152
213
|
if (skillReadGate.action === "block") {
|
|
153
214
|
return { block: true, reason: skillReadGate.reason };
|
|
154
215
|
}
|
|
@@ -193,6 +254,15 @@ export async function handleToolCall(
|
|
|
193
254
|
path: externalDirectoryPath,
|
|
194
255
|
},
|
|
195
256
|
);
|
|
257
|
+
emitDecisionEvent(deps.events, {
|
|
258
|
+
surface: toolName,
|
|
259
|
+
value: externalDirectoryPath,
|
|
260
|
+
result: "allow",
|
|
261
|
+
resolution: "infrastructure_auto_allowed",
|
|
262
|
+
origin: null,
|
|
263
|
+
agentName: agentName ?? null,
|
|
264
|
+
matchedPattern: null,
|
|
265
|
+
});
|
|
196
266
|
// Fall through to normal tool-permission check.
|
|
197
267
|
} else {
|
|
198
268
|
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
@@ -212,6 +282,15 @@ export async function handleToolCall(
|
|
|
212
282
|
resolution: "session_approved",
|
|
213
283
|
sessionApprovalPattern: extCheck.matchedPattern,
|
|
214
284
|
});
|
|
285
|
+
emitDecisionEvent(deps.events, {
|
|
286
|
+
surface: "external_directory",
|
|
287
|
+
value: externalDirectoryPath,
|
|
288
|
+
result: "allow",
|
|
289
|
+
resolution: "session_approved",
|
|
290
|
+
origin: extCheck.origin ?? null,
|
|
291
|
+
agentName: agentName ?? null,
|
|
292
|
+
matchedPattern: extCheck.matchedPattern ?? null,
|
|
293
|
+
});
|
|
215
294
|
// Fall through to normal permission check
|
|
216
295
|
} else {
|
|
217
296
|
let extDirDecision: PermissionPromptDecision | null = null;
|
|
@@ -221,9 +300,10 @@ export async function handleToolCall(
|
|
|
221
300
|
ctx.cwd,
|
|
222
301
|
agentName ?? undefined,
|
|
223
302
|
);
|
|
224
|
-
const
|
|
303
|
+
const extDirCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
304
|
+
const extDirGateResult = await applyPermissionGate({
|
|
225
305
|
state: extCheck.state,
|
|
226
|
-
canConfirm:
|
|
306
|
+
canConfirm: extDirCanConfirm,
|
|
227
307
|
promptForApproval: async () => {
|
|
228
308
|
const decision = await deps.promptPermission(ctx, {
|
|
229
309
|
requestId: (event as { toolCallId: string }).toolCallId,
|
|
@@ -262,8 +342,22 @@ export async function handleToolCall(
|
|
|
262
342
|
),
|
|
263
343
|
},
|
|
264
344
|
});
|
|
265
|
-
|
|
266
|
-
|
|
345
|
+
emitDecisionEvent(deps.events, {
|
|
346
|
+
surface: "external_directory",
|
|
347
|
+
value: externalDirectoryPath,
|
|
348
|
+
result: extDirGateResult.action === "allow" ? "allow" : "deny",
|
|
349
|
+
resolution: deriveResolution(
|
|
350
|
+
extCheck.state,
|
|
351
|
+
extDirGateResult.action,
|
|
352
|
+
extDirDecision?.state === "approved_for_session",
|
|
353
|
+
extDirCanConfirm,
|
|
354
|
+
),
|
|
355
|
+
origin: extCheck.origin ?? null,
|
|
356
|
+
agentName: agentName ?? null,
|
|
357
|
+
matchedPattern: extCheck.matchedPattern ?? null,
|
|
358
|
+
});
|
|
359
|
+
if (extDirGateResult.action === "block") {
|
|
360
|
+
return { block: true, reason: extDirGateResult.reason };
|
|
267
361
|
}
|
|
268
362
|
|
|
269
363
|
if (extDirDecision?.state === "approved_for_session") {
|
|
@@ -397,6 +491,15 @@ export async function handleToolCall(
|
|
|
397
491
|
resolution: "session_approved",
|
|
398
492
|
sessionApprovalPattern: check.matchedPattern,
|
|
399
493
|
});
|
|
494
|
+
emitDecisionEvent(deps.events, {
|
|
495
|
+
surface: toolName,
|
|
496
|
+
value: deriveDecisionValue(toolName, check),
|
|
497
|
+
result: "allow",
|
|
498
|
+
resolution: "session_approved",
|
|
499
|
+
origin: check.origin ?? null,
|
|
500
|
+
agentName: agentName ?? null,
|
|
501
|
+
matchedPattern: check.matchedPattern ?? null,
|
|
502
|
+
});
|
|
400
503
|
return {};
|
|
401
504
|
}
|
|
402
505
|
|
|
@@ -423,15 +526,17 @@ export async function handleToolCall(
|
|
|
423
526
|
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
424
527
|
|
|
425
528
|
const toolAskMessage = formatAskPrompt(check, agentName ?? undefined, input);
|
|
529
|
+
const toolCanConfirm = deps.canRequestPermissionConfirmation(ctx);
|
|
530
|
+
let toolDecisionAutoApproved = false;
|
|
426
531
|
const toolGate = await applyPermissionGate({
|
|
427
532
|
state: check.state,
|
|
428
|
-
canConfirm:
|
|
533
|
+
canConfirm: toolCanConfirm,
|
|
429
534
|
sessionApproval: {
|
|
430
535
|
surface: suggestion.surface,
|
|
431
536
|
pattern: suggestion.pattern,
|
|
432
537
|
},
|
|
433
|
-
promptForApproval: () =>
|
|
434
|
-
deps.promptPermission(ctx, {
|
|
538
|
+
promptForApproval: async () => {
|
|
539
|
+
const decision = await deps.promptPermission(ctx, {
|
|
435
540
|
requestId: (event as { toolCallId: string }).toolCallId,
|
|
436
541
|
source: "tool_call",
|
|
437
542
|
agentName,
|
|
@@ -440,7 +545,10 @@ export async function handleToolCall(
|
|
|
440
545
|
toolName,
|
|
441
546
|
sessionLabel: suggestion.label,
|
|
442
547
|
...permissionLogContext,
|
|
443
|
-
})
|
|
548
|
+
});
|
|
549
|
+
toolDecisionAutoApproved = decision.autoApproved === true;
|
|
550
|
+
return decision;
|
|
551
|
+
},
|
|
444
552
|
writeLog: deps.runtime.writeReviewLog,
|
|
445
553
|
logContext: {
|
|
446
554
|
source: "tool_call",
|
|
@@ -458,6 +566,24 @@ export async function handleToolCall(
|
|
|
458
566
|
},
|
|
459
567
|
});
|
|
460
568
|
|
|
569
|
+
const toolGateHasSession =
|
|
570
|
+
toolGate.action === "allow" && toolGate.sessionApproval !== undefined;
|
|
571
|
+
emitDecisionEvent(deps.events, {
|
|
572
|
+
surface: toolName,
|
|
573
|
+
value: deriveDecisionValue(toolName, check),
|
|
574
|
+
result: toolGate.action === "allow" ? "allow" : "deny",
|
|
575
|
+
resolution: deriveResolution(
|
|
576
|
+
check.state,
|
|
577
|
+
toolGate.action,
|
|
578
|
+
toolGateHasSession,
|
|
579
|
+
toolCanConfirm,
|
|
580
|
+
toolDecisionAutoApproved,
|
|
581
|
+
),
|
|
582
|
+
origin: check.origin ?? null,
|
|
583
|
+
agentName: agentName ?? null,
|
|
584
|
+
matchedPattern: check.matchedPattern ?? null,
|
|
585
|
+
});
|
|
586
|
+
|
|
461
587
|
if (toolGate.action === "block") {
|
|
462
588
|
return { block: true, reason: toolGate.reason };
|
|
463
589
|
}
|
package/src/handlers/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
4
|
+
import type { PermissionEventBus } from "../permission-events";
|
|
4
5
|
import type { PermissionManager } from "../permission-manager";
|
|
5
6
|
import type { ExtensionRuntime } from "../runtime";
|
|
6
7
|
|
|
@@ -33,6 +34,8 @@ export interface HandlerDeps {
|
|
|
33
34
|
// ── Runtime context ────────────────────────────────────────────────────
|
|
34
35
|
/** All mutable extension state and log-writing methods. */
|
|
35
36
|
readonly runtime: ExtensionRuntime;
|
|
37
|
+
/** Event bus for emitting permissions:decision broadcast events. */
|
|
38
|
+
readonly events: PermissionEventBus;
|
|
36
39
|
|
|
37
40
|
// ── Factories ──────────────────────────────────────────────────────────
|
|
38
41
|
/** Create a new PermissionManager scoped to cwd's config hierarchy. */
|
|
@@ -67,6 +70,8 @@ export interface HandlerDeps {
|
|
|
67
70
|
// ── Forwarding ─────────────────────────────────────────────────────────
|
|
68
71
|
startForwardedPermissionPolling(ctx: ExtensionContext): void;
|
|
69
72
|
stopForwardedPermissionPolling(): void;
|
|
73
|
+
/** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
|
|
74
|
+
stopPermissionRpcHandlers(): void;
|
|
70
75
|
|
|
71
76
|
// ── Pi API subset ──────────────────────────────────────────────────────
|
|
72
77
|
getAllTools(): unknown[];
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
handleToolCall,
|
|
13
13
|
} from "./handlers";
|
|
14
14
|
import { requestPermissionDecisionFromUi } from "./permission-dialog";
|
|
15
|
+
import { registerPermissionRpcHandlers } from "./permission-event-rpc";
|
|
16
|
+
import { emitReadyEvent } from "./permission-events";
|
|
15
17
|
import { PermissionPrompter } from "./permission-prompter";
|
|
16
18
|
import {
|
|
17
19
|
createExtensionRuntime,
|
|
@@ -67,8 +69,17 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
67
69
|
const createPermissionRequestId = (prefix: string): string =>
|
|
68
70
|
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
69
71
|
|
|
72
|
+
const rpcHandles = registerPermissionRpcHandlers(pi.events, {
|
|
73
|
+
getPermissionManager: () => runtime.permissionManager,
|
|
74
|
+
getSessionRules: () => runtime.sessionRules.getRuleset(),
|
|
75
|
+
getRuntimeContext: () => runtime.runtimeContext,
|
|
76
|
+
requestPermissionDecisionFromUi,
|
|
77
|
+
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
78
|
+
});
|
|
79
|
+
|
|
70
80
|
const deps: HandlerDeps = {
|
|
71
81
|
runtime,
|
|
82
|
+
events: pi.events,
|
|
72
83
|
createPermissionManagerForCwd: (cwd) =>
|
|
73
84
|
createPermissionManagerForCwd(runtime.agentDir, cwd),
|
|
74
85
|
refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
|
|
@@ -92,10 +103,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
92
103
|
startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
|
|
93
104
|
stopForwardedPermissionPolling: () =>
|
|
94
105
|
stopForwardedPermissionPolling(runtime),
|
|
106
|
+
stopPermissionRpcHandlers: () => {
|
|
107
|
+
rpcHandles.unsubCheck();
|
|
108
|
+
rpcHandles.unsubPrompt();
|
|
109
|
+
},
|
|
95
110
|
getAllTools: () => pi.getAllTools(),
|
|
96
111
|
setActiveTools: (names) => pi.setActiveTools(names),
|
|
97
112
|
};
|
|
98
113
|
|
|
114
|
+
emitReadyEvent(pi.events);
|
|
115
|
+
|
|
99
116
|
pi.on("session_start", (event, ctx) => handleSessionStart(deps, event, ctx));
|
|
100
117
|
pi.on("resources_discover", (event) => handleResourcesDiscover(deps, event));
|
|
101
118
|
pi.on("session_shutdown", () => handleSessionShutdown(deps));
|
package/src/permission-dialog.ts
CHANGED
|
@@ -8,6 +8,12 @@ export type PermissionPromptDecision = {
|
|
|
8
8
|
approved: boolean;
|
|
9
9
|
state: PermissionDecisionState;
|
|
10
10
|
denialReason?: string;
|
|
11
|
+
/**
|
|
12
|
+
* True when the decision was made automatically by yolo mode rather than
|
|
13
|
+
* by an interactive user prompt. Used by handlers to emit "auto_approved"
|
|
14
|
+
* rather than "user_approved" in the permissions:decision broadcast.
|
|
15
|
+
*/
|
|
16
|
+
autoApproved?: true;
|
|
11
17
|
};
|
|
12
18
|
|
|
13
19
|
export interface PermissionDecisionUi {
|