@gotgenes/pi-permission-system 25.3.0 → 25.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/dist/public.d.ts +6 -0
- package/docs/cross-extension-api.md +14 -9
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +26 -1
- package/src/handlers/gates/descriptor.ts +10 -1
- package/src/handlers/gates/helpers.ts +6 -7
- package/src/handlers/gates/runner.ts +40 -19
- package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
- package/src/handlers/gates/skill-read.ts +1 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
- package/src/handlers/tool-call-boundary.ts +30 -7
- package/src/permission-events.ts +6 -0
- package/src/permission-request-id.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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
|
+
## [25.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.3.0...pi-permission-system-v25.4.0) (2026-08-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** adopt the requester's request id as the forwarded request id ([3f8d3fd](https://github.com/gotgenes/pi-packages/commit/3f8d3fd69940211c6a92b99f8eae0396936afedf)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
|
|
14
|
+
* **pi-permission-system:** carry the request id on permissions:decision ([95c001c](https://github.com/gotgenes/pi-packages/commit/95c001cf6b35c68991143826d8621b1e94fc0b32)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
|
|
15
|
+
* **pi-permission-system:** give the gate-error review entry a request id ([172bc68](https://github.com/gotgenes/pi-packages/commit/172bc689ea0f18c88672a22c0b5e84c0f3576fd1)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
|
|
16
|
+
* **pi-permission-system:** mint a permission request id at request creation ([336842d](https://github.com/gotgenes/pi-packages/commit/336842de6c34e96bd90ed411eaca9649c4b9fac2)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** record the minted request id in the API and architecture docs ([84f5856](https://github.com/gotgenes/pi-packages/commit/84f5856868a548c9454ef3e23ca75c608ce3f308)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
|
|
22
|
+
|
|
8
23
|
## [25.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.2...pi-permission-system-v25.3.0) (2026-08-15)
|
|
9
24
|
|
|
10
25
|
|
package/dist/public.d.ts
CHANGED
|
@@ -207,6 +207,12 @@ interface PermissionUiPromptEvent {
|
|
|
207
207
|
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable";
|
|
208
208
|
/** Payload emitted on `permissions:decision`. */
|
|
209
209
|
interface PermissionDecisionEvent {
|
|
210
|
+
/**
|
|
211
|
+
* Identifies the permission request this decision resolves, minted when the
|
|
212
|
+
* request was created. Distinct from the host's tool-call id: one tool call
|
|
213
|
+
* runs several gates and so raises several requests.
|
|
214
|
+
*/
|
|
215
|
+
requestId: string;
|
|
210
216
|
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
211
217
|
surface: string;
|
|
212
218
|
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
@@ -336,15 +336,15 @@ pi.events.on("permissions:ui_prompt", (raw) => {
|
|
|
336
336
|
|
|
337
337
|
### Payload Fields
|
|
338
338
|
|
|
339
|
-
| Field | Type | Description
|
|
340
|
-
| ------------ | -------------------------------- |
|
|
341
|
-
| `requestId` | `string` |
|
|
342
|
-
| `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"`
|
|
343
|
-
| `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known
|
|
344
|
-
| `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known
|
|
345
|
-
| `agentName` | `string \| null` | Active/requesting agent name, when known
|
|
346
|
-
| `message` | `string` | Message displayed in the permission prompt
|
|
347
|
-
| `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt
|
|
339
|
+
| Field | Type | Description |
|
|
340
|
+
| ------------ | -------------------------------- | ----------------------------------------------------------------------- |
|
|
341
|
+
| `requestId` | `string` | Id of the permission request being prompted, minted when it was created |
|
|
342
|
+
| `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
|
|
343
|
+
| `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
|
|
344
|
+
| `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
|
|
345
|
+
| `agentName` | `string \| null` | Active/requesting agent name, when known |
|
|
346
|
+
| `message` | `string` | Message displayed in the permission prompt |
|
|
347
|
+
| `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
|
|
348
348
|
|
|
349
349
|
Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its original `source` and is identified by a non-null `forwarding` field, not by a dedicated source value.
|
|
350
350
|
|
|
@@ -367,6 +367,10 @@ The stability guarantee is additive, so any can be reintroduced in a later minor
|
|
|
367
367
|
Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
|
|
368
368
|
This is useful for dashboards, telemetry, or audit overlays.
|
|
369
369
|
|
|
370
|
+
The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
|
|
371
|
+
It identifies a permission *request*, not a tool call: one tool call runs several gates and so raises several requests, each with its own id.
|
|
372
|
+
Use the review log's `toolCallId` to join back to the Pi transcript.
|
|
373
|
+
|
|
370
374
|
```typescript
|
|
371
375
|
pi.events.on("permissions:decision", (raw) => {
|
|
372
376
|
const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
|
|
@@ -379,6 +383,7 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
379
383
|
|
|
380
384
|
| Field | Type | Description |
|
|
381
385
|
| ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
|
|
386
|
+
| `requestId` | `string` | Id of the permission request this decision resolves |
|
|
382
387
|
| `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
|
|
383
388
|
| `value` | `string` | Value evaluated (command, tool name, skill name, path) |
|
|
384
389
|
| `result` | `"allow" \| "deny"` | Final outcome |
|
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "#src/authority/permission-forwarding";
|
|
35
35
|
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
36
36
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
37
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
37
38
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
38
39
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
39
40
|
import { toRecord } from "#src/value-guards";
|
|
@@ -75,6 +76,12 @@ function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
|
75
76
|
* relayed value instead of three positional optionals.
|
|
76
77
|
*/
|
|
77
78
|
interface ForwardedRequestFacts {
|
|
79
|
+
/**
|
|
80
|
+
* The requester's own permission request id, adopted as the forwarded
|
|
81
|
+
* request's id so one id runs from the child's gate to the serving node's
|
|
82
|
+
* decision instead of a third being minted here.
|
|
83
|
+
*/
|
|
84
|
+
requestId: string;
|
|
78
85
|
message: string;
|
|
79
86
|
display?: ForwardedPromptDisplay;
|
|
80
87
|
sessionApproval?: ForwardedSessionApproval;
|
|
@@ -111,6 +118,23 @@ function abandon(denialReason: string): PermissionPromptDecision {
|
|
|
111
118
|
};
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
/** Ids this node is willing to use as a request/response filename. */
|
|
122
|
+
const FILENAME_SAFE_REQUEST_ID = /^[A-Za-z0-9._-]+$/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The id to write on the forwarded request: the requester's own, or a fresh
|
|
126
|
+
* mint when that id could not safely name a file.
|
|
127
|
+
*
|
|
128
|
+
* At a relay hop the adopted id came from a request file on disk, which the
|
|
129
|
+
* tolerant reader validates only as a string — so this is the boundary that
|
|
130
|
+
* keeps an inbound id from choosing an outbound path.
|
|
131
|
+
*/
|
|
132
|
+
function forwardableRequestId(requesterRequestId: string): string {
|
|
133
|
+
return FILENAME_SAFE_REQUEST_ID.test(requesterRequestId)
|
|
134
|
+
? requesterRequestId
|
|
135
|
+
: createPermissionRequestId();
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/**
|
|
115
139
|
* Authorizer for a subagent session: escalate the ask up the tree to the
|
|
116
140
|
* parent's authority.
|
|
@@ -146,6 +170,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
146
170
|
): Promise<PermissionPromptDecision> {
|
|
147
171
|
const uiPrompt = buildUiPrompt(details);
|
|
148
172
|
return this.waitForForwardedApproval(this.ctx, {
|
|
173
|
+
requestId: details.requestId,
|
|
149
174
|
message: details.message,
|
|
150
175
|
display: {
|
|
151
176
|
source: uiPrompt.source,
|
|
@@ -250,7 +275,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
250
275
|
requesterSessionId: string,
|
|
251
276
|
targetSessionId: string,
|
|
252
277
|
): ForwardedPermissionRequest {
|
|
253
|
-
const requestId =
|
|
278
|
+
const requestId = forwardableRequestId(facts.requestId);
|
|
254
279
|
const requesterAgentName =
|
|
255
280
|
getActiveAgentName(ctx) ??
|
|
256
281
|
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
@@ -51,6 +51,15 @@ export interface GateDescriptor {
|
|
|
51
51
|
preCheck?: PermissionCheckResult;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A decision event's facts, before the runner stamps the request id it minted.
|
|
56
|
+
*
|
|
57
|
+
* A gate knows what was decided but not which request it was deciding — the id
|
|
58
|
+
* is minted in `GateRunner.run`. Producing this type rather than the full event
|
|
59
|
+
* is what routes every emit through the runner's single stamping site.
|
|
60
|
+
*/
|
|
61
|
+
export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
|
|
62
|
+
|
|
54
63
|
/**
|
|
55
64
|
* Early allow result — gate has determined the action without needing the runner.
|
|
56
65
|
*
|
|
@@ -62,7 +71,7 @@ export interface GateBypass {
|
|
|
62
71
|
/** Optional review log entry to emit. */
|
|
63
72
|
log?: { event: string; details: Record<string, unknown> };
|
|
64
73
|
/** Optional decision event to emit. */
|
|
65
|
-
decision?:
|
|
74
|
+
decision?: DecisionEventFacts;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/** Union of possible gate function return values. */
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
3
3
|
import type { ForwardedAccessFacts } from "#src/authority/permission-forwarding";
|
|
4
|
-
import type {
|
|
5
|
-
PermissionDecisionEvent,
|
|
6
|
-
PermissionDecisionResolution,
|
|
7
|
-
} from "#src/permission-events";
|
|
4
|
+
import type { PermissionDecisionResolution } from "#src/permission-events";
|
|
8
5
|
import type { PermissionCheckResult } from "#src/types";
|
|
6
|
+
import type { DecisionEventFacts } from "./descriptor";
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Build the child-fixed access facts for a path-shaped gate from its
|
|
@@ -62,11 +60,12 @@ export function deriveDecisionValue(
|
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
/**
|
|
65
|
-
* Build a
|
|
63
|
+
* Build a decision event's facts from the gate's inputs.
|
|
66
64
|
*
|
|
67
65
|
* Centralises the `origin / agentName / matchedPattern ?? null` normalization
|
|
68
66
|
* that is otherwise duplicated across the session-hit path and the gate-result
|
|
69
|
-
* path in `runGateCheck`.
|
|
67
|
+
* path in `runGateCheck`. The request id is stamped by the runner, which is
|
|
68
|
+
* where it was minted.
|
|
70
69
|
*/
|
|
71
70
|
export function buildDecisionEvent(
|
|
72
71
|
decision: { surface: string; value: string },
|
|
@@ -74,7 +73,7 @@ export function buildDecisionEvent(
|
|
|
74
73
|
agentName: string | null,
|
|
75
74
|
result: "allow" | "deny",
|
|
76
75
|
resolution: PermissionDecisionResolution,
|
|
77
|
-
):
|
|
76
|
+
): DecisionEventFacts {
|
|
78
77
|
return {
|
|
79
78
|
surface: decision.surface,
|
|
80
79
|
value: decision.value,
|
|
@@ -7,10 +7,15 @@ import {
|
|
|
7
7
|
formatUserDeniedReason,
|
|
8
8
|
} from "#src/denial-messages";
|
|
9
9
|
import { applyPermissionGate } from "#src/permission-gate";
|
|
10
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
10
11
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
11
12
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
12
13
|
import type { PermissionCheckResult } from "#src/types";
|
|
13
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
DecisionEventFacts,
|
|
16
|
+
GateDescriptor,
|
|
17
|
+
GateResult,
|
|
18
|
+
} from "./descriptor";
|
|
14
19
|
import { isGateBypass } from "./descriptor";
|
|
15
20
|
import {
|
|
16
21
|
buildDecisionEvent,
|
|
@@ -46,33 +51,44 @@ export class GateRunner {
|
|
|
46
51
|
/**
|
|
47
52
|
* Execute a gate: null → allow; bypass → log/emit side effects then allow;
|
|
48
53
|
* descriptor → full check→log→emit→approve cycle.
|
|
54
|
+
*
|
|
55
|
+
* The request id is minted here, before the branch, so a request that never
|
|
56
|
+
* prompts is identified exactly as one that does.
|
|
49
57
|
*/
|
|
50
|
-
async run(
|
|
51
|
-
gate: GateResult,
|
|
52
|
-
agentName: string | null,
|
|
53
|
-
toolCallId: string,
|
|
54
|
-
): Promise<GateOutcome> {
|
|
58
|
+
async run(gate: GateResult, agentName: string | null): Promise<GateOutcome> {
|
|
55
59
|
if (!gate) {
|
|
56
60
|
return { action: "allow" };
|
|
57
61
|
}
|
|
62
|
+
const requestId = createPermissionRequestId();
|
|
58
63
|
if (isGateBypass(gate)) {
|
|
59
64
|
if (gate.log) {
|
|
60
|
-
this.reporter.writeReviewLog(gate.log.event,
|
|
65
|
+
this.reporter.writeReviewLog(gate.log.event, {
|
|
66
|
+
...gate.log.details,
|
|
67
|
+
requestId,
|
|
68
|
+
});
|
|
61
69
|
}
|
|
62
70
|
if (gate.decision) {
|
|
63
|
-
this.
|
|
71
|
+
this.emitDecision(requestId, gate.decision);
|
|
64
72
|
}
|
|
65
73
|
return { action: "allow" };
|
|
66
74
|
}
|
|
67
|
-
return this.runDescriptor(gate, agentName,
|
|
75
|
+
return this.runDescriptor(gate, agentName, requestId);
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
// ── Private helpers ──────────────────────────────────────────────────────
|
|
71
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The one place a decision event acquires its request id, so no emit path
|
|
82
|
+
* can be added that forgets it.
|
|
83
|
+
*/
|
|
84
|
+
private emitDecision(requestId: string, facts: DecisionEventFacts): void {
|
|
85
|
+
this.reporter.emitDecision({ requestId, ...facts });
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
private async runDescriptor(
|
|
73
89
|
descriptor: GateDescriptor,
|
|
74
90
|
agentName: string | null,
|
|
75
|
-
|
|
91
|
+
requestId: string,
|
|
76
92
|
): Promise<GateOutcome> {
|
|
77
93
|
// 1. Resolve permission state — pre-check, pre-resolved, or via resolver
|
|
78
94
|
let check: PermissionCheckResult;
|
|
@@ -94,15 +110,19 @@ export class GateRunner {
|
|
|
94
110
|
});
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
// The fields every review-log write for this gate shares, whatever the
|
|
114
|
+
// resolution — built once so a field added here reaches all of them.
|
|
115
|
+
const logContext = { ...descriptor.logContext, agentName, requestId };
|
|
116
|
+
|
|
97
117
|
// 2. Session-hit fast path
|
|
98
118
|
if (check.source === "session") {
|
|
99
119
|
this.reporter.writeReviewLog("permission_request.session_approved", {
|
|
100
|
-
...
|
|
101
|
-
agentName,
|
|
120
|
+
...logContext,
|
|
102
121
|
resolution: "session_approved",
|
|
103
122
|
sessionApprovalPattern: check.matchedPattern,
|
|
104
123
|
});
|
|
105
|
-
this.
|
|
124
|
+
this.emitDecision(
|
|
125
|
+
requestId,
|
|
106
126
|
buildDecisionEvent(
|
|
107
127
|
descriptor.decision,
|
|
108
128
|
check,
|
|
@@ -121,11 +141,11 @@ export class GateRunner {
|
|
|
121
141
|
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
|
|
122
142
|
if (yoloGrant) {
|
|
123
143
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
124
|
-
...
|
|
125
|
-
agentName,
|
|
144
|
+
...logContext,
|
|
126
145
|
resolution: "auto_approved",
|
|
127
146
|
});
|
|
128
|
-
this.
|
|
147
|
+
this.emitDecision(
|
|
148
|
+
requestId,
|
|
129
149
|
buildDecisionEvent(
|
|
130
150
|
descriptor.decision,
|
|
131
151
|
yoloGrant,
|
|
@@ -159,7 +179,7 @@ export class GateRunner {
|
|
|
159
179
|
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
|
160
180
|
promptForApproval: async () => {
|
|
161
181
|
const decision = await this.prompter.escalate({
|
|
162
|
-
requestId
|
|
182
|
+
requestId,
|
|
163
183
|
...descriptor.promptDetails,
|
|
164
184
|
...(descriptor.sessionApproval
|
|
165
185
|
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
|
|
@@ -171,7 +191,7 @@ export class GateRunner {
|
|
|
171
191
|
},
|
|
172
192
|
writeLog: (event, details) =>
|
|
173
193
|
this.reporter.writeReviewLog(event, details),
|
|
174
|
-
logContext
|
|
194
|
+
logContext,
|
|
175
195
|
messages,
|
|
176
196
|
});
|
|
177
197
|
|
|
@@ -180,7 +200,8 @@ export class GateRunner {
|
|
|
180
200
|
gateResult.action === "allow" && gateResult.sessionApproval !== undefined;
|
|
181
201
|
|
|
182
202
|
// 5. Emit decision event
|
|
183
|
-
this.
|
|
203
|
+
this.emitDecision(
|
|
204
|
+
requestId,
|
|
184
205
|
buildDecisionEvent(
|
|
185
206
|
descriptor.decision,
|
|
186
207
|
check,
|
|
@@ -40,7 +40,7 @@ export interface GateNotifier {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Owns the skill-input gate assembly: raw permission pre-check, deny notify,
|
|
43
|
-
* `describeSkillInputGate` descriptor,
|
|
43
|
+
* `describeSkillInputGate` descriptor, and `runner.run(...)`.
|
|
44
44
|
*
|
|
45
45
|
* Constructed once in the composition root and injected into
|
|
46
46
|
* `PermissionGateHandler`, mirroring `ToolCallGatePipeline` for the `input`
|
|
@@ -70,23 +70,12 @@ export class SkillInputGatePipeline {
|
|
|
70
70
|
return runner.run(
|
|
71
71
|
describeSkillInputGate(skillName, agentName, check),
|
|
72
72
|
agentName,
|
|
73
|
-
createSkillInputRequestId(),
|
|
74
73
|
);
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
79
78
|
|
|
80
|
-
/**
|
|
81
|
-
* Mint a unique id for a skill-input permission request.
|
|
82
|
-
*
|
|
83
|
-
* Format is `skill-input-<timestamp>-<random>-<pid>`, matching the
|
|
84
|
-
* `createPermissionRequestId("skill-input")` pattern it replaces (#330).
|
|
85
|
-
*/
|
|
86
|
-
export function createSkillInputRequestId(): string {
|
|
87
|
-
return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
79
|
/**
|
|
91
80
|
* Format the deny warning shown in the UI when a skill is blocked.
|
|
92
81
|
*
|
|
@@ -137,11 +137,7 @@ export class ToolCallGatePipeline {
|
|
|
137
137
|
];
|
|
138
138
|
|
|
139
139
|
for (const produce of gateProducers) {
|
|
140
|
-
const outcome = await runner.run(
|
|
141
|
-
await produce(),
|
|
142
|
-
tcc.agentName,
|
|
143
|
-
tcc.toolCallId,
|
|
144
|
-
);
|
|
140
|
+
const outcome = await runner.run(await produce(), tcc.agentName);
|
|
145
141
|
if (outcome.action === "block") {
|
|
146
142
|
return outcome;
|
|
147
143
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { DecisionRecorder } from "#src/decision-audit";
|
|
3
3
|
import type { DecisionReporter } from "#src/decision-reporter";
|
|
4
|
+
import { createPermissionRequestId } from "#src/permission-request-id";
|
|
4
5
|
import { toRecord } from "#src/value-guards";
|
|
5
6
|
import type { GateOutcome } from "./gates/types";
|
|
6
7
|
|
|
@@ -53,18 +54,40 @@ export function createFailClosedToolCall(
|
|
|
53
54
|
? { block: true, reason: outcome.reason }
|
|
54
55
|
: {};
|
|
55
56
|
} catch (error) {
|
|
56
|
-
audit
|
|
57
|
-
reporter.writeReviewLog("permission_request.blocked", {
|
|
58
|
-
toolName: bestEffortToolName(event),
|
|
59
|
-
command: bestEffortCommand(event),
|
|
60
|
-
resolution: "gate_error",
|
|
61
|
-
error: errorMessage(error),
|
|
62
|
-
});
|
|
57
|
+
recordGateError(reporter, audit, event, error);
|
|
63
58
|
return { block: true, reason: formatGateErrorReason(error) };
|
|
64
59
|
}
|
|
65
60
|
};
|
|
66
61
|
}
|
|
67
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Record a gate error without ever throwing.
|
|
65
|
+
*
|
|
66
|
+
* The block below this must be reached: the SDK does not catch a throwing
|
|
67
|
+
* handler, so an exception escaping the recording work would leave the command
|
|
68
|
+
* ungated. The request id is minted here rather than borrowed — the throw may
|
|
69
|
+
* have come from anywhere in the pipeline, so no gate's id is available.
|
|
70
|
+
*/
|
|
71
|
+
function recordGateError(
|
|
72
|
+
reporter: DecisionReporter,
|
|
73
|
+
audit: DecisionRecorder,
|
|
74
|
+
event: unknown,
|
|
75
|
+
error: unknown,
|
|
76
|
+
): void {
|
|
77
|
+
try {
|
|
78
|
+
audit.recordError();
|
|
79
|
+
reporter.writeReviewLog("permission_request.blocked", {
|
|
80
|
+
requestId: createPermissionRequestId(),
|
|
81
|
+
toolName: bestEffortToolName(event),
|
|
82
|
+
command: bestEffortCommand(event),
|
|
83
|
+
resolution: "gate_error",
|
|
84
|
+
error: errorMessage(error),
|
|
85
|
+
});
|
|
86
|
+
} catch {
|
|
87
|
+
// The block is the guarantee; its bookkeeping is not.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
// ── Defensive event readers (never throw) ──────────────────────────────────
|
|
69
92
|
|
|
70
93
|
/** Best-effort tool name from a raw event; never throws. */
|
package/src/permission-events.ts
CHANGED
|
@@ -101,6 +101,12 @@ export type PermissionDecisionResolution =
|
|
|
101
101
|
|
|
102
102
|
/** Payload emitted on `permissions:decision`. */
|
|
103
103
|
export interface PermissionDecisionEvent {
|
|
104
|
+
/**
|
|
105
|
+
* Identifies the permission request this decision resolves, minted when the
|
|
106
|
+
* request was created. Distinct from the host's tool-call id: one tool call
|
|
107
|
+
* runs several gates and so raises several requests.
|
|
108
|
+
*/
|
|
109
|
+
requestId: string;
|
|
104
110
|
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
105
111
|
surface: string;
|
|
106
112
|
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mint the identifier for one permission request, at the moment the request is
|
|
5
|
+
* created rather than at the moment it prompts.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from the host's `toolCallId`, which keeps flowing alongside it as
|
|
8
|
+
* the join back to the Pi transcript: a single tool call runs several gates and
|
|
9
|
+
* therefore raises several permission requests, so the SDK's id cannot identify
|
|
10
|
+
* one of them.
|
|
11
|
+
*
|
|
12
|
+
* The `perm-` prefix keeps the id self-identifying in a review log that also
|
|
13
|
+
* carries SDK tool-call ids.
|
|
14
|
+
*/
|
|
15
|
+
export function createPermissionRequestId(): string {
|
|
16
|
+
return `perm-${randomUUID()}`;
|
|
17
|
+
}
|