@gotgenes/pi-permission-system 26.2.1 → 26.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 +20 -0
- package/README.md +2 -2
- package/dist/public.d.ts +13 -1
- package/docs/cross-extension-api.md +19 -10
- package/package.json +1 -1
- package/src/authority/forwarded-request-server.ts +99 -3
- package/src/decision-reporter.ts +13 -2
- package/src/handlers/gates/bash-external-directory.ts +5 -2
- package/src/handlers/gates/bash-path.ts +3 -2
- package/src/handlers/gates/external-directory.ts +1 -2
- package/src/handlers/gates/path.ts +1 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +19 -10
- package/src/handlers/gates/tool.ts +31 -14
- package/src/handlers/tool-call-boundary.ts +22 -4
- package/src/index.ts +8 -1
- package/src/path/approval-pattern.ts +27 -0
- package/src/path/path-flavor.ts +22 -2
- package/src/path-normalizer.ts +15 -0
- package/src/pattern-suggest.ts +27 -18
- package/src/permission-events.ts +13 -1
- package/src/session-rules.ts +0 -31
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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
|
+
## [26.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.2...pi-permission-system-v26.3.0) (2026-08-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** broadcast a terminal decision when a gate error blocks a tool call ([2ccb8e5](https://github.com/gotgenes/pi-packages/commit/2ccb8e50a4ef376ced8448efba2d5f9f3658e98f)), closes [#753](https://github.com/gotgenes/pi-packages/issues/753)
|
|
14
|
+
* **pi-permission-system:** broadcast the terminal decision for a served forwarded ask ([f2d6b17](https://github.com/gotgenes/pi-packages/commit/f2d6b176abdd47a2b3ce5c3cf6973ba316d5eea6)), closes [#610](https://github.com/gotgenes/pi-packages/issues/610)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** document the served forwarded decision broadcast ([fc2b00b](https://github.com/gotgenes/pi-packages/commit/fc2b00bb801468b450a725e0cb4e9da7e5279844))
|
|
20
|
+
|
|
21
|
+
## [26.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.1...pi-permission-system-v26.2.2) (2026-08-18)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** derive session-approval patterns through the injected PathFlavor ([cf561de](https://github.com/gotgenes/pi-packages/commit/cf561de4c19895ed9495d82f22280fe3ba215aa5)), closes [#655](https://github.com/gotgenes/pi-packages/issues/655)
|
|
27
|
+
|
|
8
28
|
## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
|
|
9
29
|
|
|
10
30
|
|
package/README.md
CHANGED
|
@@ -19,9 +19,9 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
19
19
|
- **Gates MCP and skill access** at server, tool, and skill-name granularity
|
|
20
20
|
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
|
-
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
|
|
22
|
+
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
|
|
23
23
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
24
|
-
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
|
|
24
|
+
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
|
|
25
25
|
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
|
26
26
|
|
|
27
27
|
## Install
|
package/dist/public.d.ts
CHANGED
|
@@ -225,7 +225,9 @@ interface PermissionUiPromptEvent {
|
|
|
225
225
|
forwarding: ForwardedPromptContext | null;
|
|
226
226
|
}
|
|
227
227
|
/** How a permission decision was reached. */
|
|
228
|
-
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
|
|
228
|
+
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
|
|
229
|
+
/** The gate threw, or an escalation failed, and the request was blocked. */
|
|
230
|
+
| "gate_error";
|
|
229
231
|
/** Payload emitted on `permissions:decision`. */
|
|
230
232
|
interface PermissionDecisionEvent {
|
|
231
233
|
/**
|
|
@@ -248,6 +250,16 @@ interface PermissionDecisionEvent {
|
|
|
248
250
|
agentName: string | null;
|
|
249
251
|
/** Matched pattern from the winning rule (when available). */
|
|
250
252
|
matchedPattern: string | null;
|
|
253
|
+
/**
|
|
254
|
+
* Forwarding context for a decision this session made while serving another
|
|
255
|
+
* session's forwarded request; absent on an ordinary local decision.
|
|
256
|
+
*
|
|
257
|
+
* The same `ForwardedPromptContext` the request's `permissions:ui_prompt`
|
|
258
|
+
* carried, so a consumer that never saw the prompt can still tell a served
|
|
259
|
+
* ask from a local one. Requester identity beyond it — the requester's cwd
|
|
260
|
+
* and principal — stays off the bus.
|
|
261
|
+
*/
|
|
262
|
+
forwarding?: ForwardedPromptContext | null;
|
|
251
263
|
}
|
|
252
264
|
|
|
253
265
|
/**
|
|
@@ -315,6 +315,7 @@ It is not a generic "permission request entered waiting state" event, and it doe
|
|
|
315
315
|
Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
|
|
316
316
|
Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
|
|
317
317
|
A forwarded request the parent's own recorded policy decides (a matching `allow` or `deny`) is answered without a prompt and emits no event; the event fires only when the parent is actually about to ask the human.
|
|
318
|
+
The matching terminal `permissions:decision` is emitted in the parent session too, so a consumer that reacts to this event has a signal on the same bus telling it the prompt is over.
|
|
318
319
|
Forwarded prompts that do reach the human are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
|
|
319
320
|
|
|
320
321
|
The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
|
|
@@ -393,7 +394,13 @@ The stability guarantee is additive, so any can be reintroduced in a later minor
|
|
|
393
394
|
Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
|
|
394
395
|
This is useful for dashboards, telemetry, or audit overlays.
|
|
395
396
|
|
|
397
|
+
A session serving another session's forwarded request emits one too, on its own bus, for every forwarded ask it escalates.
|
|
398
|
+
That is what makes a forwarded prompt clearable: the ask is gated in the requesting session — a different process for an out-of-process subagent — so without it the serving session broadcasts a `permissions:ui_prompt` whose outcome never appears.
|
|
399
|
+
A forwarded request the serving session's own policy allows or denies is answered without a prompt and broadcasts nothing, matching the UI-prompt channel.
|
|
400
|
+
A served decision carries a non-null `forwarding` context; the requesting session still emits its own decision when the answer comes back.
|
|
401
|
+
|
|
396
402
|
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.
|
|
403
|
+
A request that reaches a prompt is answered by exactly one terminal event on that prompt's own bus, including when the dialog itself fails.
|
|
397
404
|
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.
|
|
398
405
|
Use the review log's `toolCallId` to join back to the Pi transcript.
|
|
399
406
|
|
|
@@ -407,16 +414,17 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
407
414
|
|
|
408
415
|
### Payload Fields
|
|
409
416
|
|
|
410
|
-
| Field | Type
|
|
411
|
-
| ---------------- |
|
|
412
|
-
| `requestId` | `string`
|
|
413
|
-
| `surface` | `string`
|
|
414
|
-
| `value` | `string`
|
|
415
|
-
| `result` | `"allow" \| "deny"`
|
|
416
|
-
| `resolution` | `string`
|
|
417
|
-
| `origin` | `string \| null`
|
|
418
|
-
| `agentName` | `string \| null`
|
|
419
|
-
| `matchedPattern` | `string \| null`
|
|
417
|
+
| Field | Type | Description |
|
|
418
|
+
| ---------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
419
|
+
| `requestId` | `string` | Id of the permission request this decision resolves |
|
|
420
|
+
| `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
|
|
421
|
+
| `value` | `string` | Value evaluated (command, tool name, skill name, path) |
|
|
422
|
+
| `result` | `"allow" \| "deny"` | Final outcome |
|
|
423
|
+
| `resolution` | `string` | How the outcome was reached (see table below) |
|
|
424
|
+
| `origin` | `string \| null` | Config scope that contributed the winning rule |
|
|
425
|
+
| `agentName` | `string \| null` | Active agent name when known |
|
|
426
|
+
| `matchedPattern` | `string \| null` | Pattern from the winning rule |
|
|
427
|
+
| `forwarding` | `ForwardedPromptContext \| null` (optional) | Requesting subagent, on a decision made while serving a forwarded request; absent on a local decision |
|
|
420
428
|
|
|
421
429
|
### Resolution Values
|
|
422
430
|
|
|
@@ -431,6 +439,7 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
431
439
|
| `user_denied` | User denied via dialog |
|
|
432
440
|
| `auto_approved` | Yolo mode — approved automatically without dialog |
|
|
433
441
|
| `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
|
|
442
|
+
| `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
|
|
434
443
|
|
|
435
444
|
---
|
|
436
445
|
|
package/package.json
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
type PermissionForwardingLocation,
|
|
15
15
|
} from "#src/authority/permission-forwarding";
|
|
16
16
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
17
|
+
import type { DecisionBroadcaster } from "#src/decision-reporter";
|
|
18
|
+
import type {
|
|
19
|
+
PermissionDecisionEvent,
|
|
20
|
+
PermissionDecisionResolution,
|
|
21
|
+
} from "#src/permission-events";
|
|
17
22
|
import { buildForwardedAskPayload } from "#src/presentation/forwarded-ask-payload";
|
|
18
23
|
import { SessionApproval } from "#src/session-approval";
|
|
19
24
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
@@ -70,6 +75,14 @@ export interface ForwardedRequestServerDeps {
|
|
|
70
75
|
policy: ServingPolicy;
|
|
71
76
|
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
|
72
77
|
escalator: AskEscalator;
|
|
78
|
+
/**
|
|
79
|
+
* Terminal-decision broadcast for an ask this session served.
|
|
80
|
+
*
|
|
81
|
+
* A forwarded ask is prompted here but gated in the requesting session — on
|
|
82
|
+
* another event bus for an out-of-process child — so without this the
|
|
83
|
+
* parent's own consumers observe a prompt that never ends (#610).
|
|
84
|
+
*/
|
|
85
|
+
broadcaster: DecisionBroadcaster;
|
|
73
86
|
/**
|
|
74
87
|
* The serving session's `SessionRules`. Records a whole-session grant when a
|
|
75
88
|
* human approves a forwarded request for the entire serving session.
|
|
@@ -143,6 +156,68 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
|
143
156
|
};
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Build the terminal `permissions:decision` for an ask this session served.
|
|
161
|
+
*
|
|
162
|
+
* Rendered from the same {@link PromptPermissionDetails} the `ui_prompt`
|
|
163
|
+
* broadcast was built from, so prompt and decision carry one projection by
|
|
164
|
+
* construction rather than by convention — which is what makes them joinable
|
|
165
|
+
* beyond the shared request id.
|
|
166
|
+
*
|
|
167
|
+
* `origin` and `matchedPattern` are `null` by construction: an escalated
|
|
168
|
+
* request is one recorded authority did *not* decide, so no rule won. The
|
|
169
|
+
* decider stays off the bus, which discloses request facts and verdicts only
|
|
170
|
+
* (ADR 0011 §6, #726).
|
|
171
|
+
*/
|
|
172
|
+
function buildServedDecisionEvent(
|
|
173
|
+
details: PromptPermissionDetails,
|
|
174
|
+
decision: PermissionPromptDecision,
|
|
175
|
+
): PermissionDecisionEvent {
|
|
176
|
+
const facts = details.payload.request;
|
|
177
|
+
return {
|
|
178
|
+
requestId: details.requestId,
|
|
179
|
+
// The child's display projection, falling back to the payload's own facts
|
|
180
|
+
// for a version-skewed request that carried none. Both are non-nullable
|
|
181
|
+
// there, so the event's non-null contract holds without a sentinel.
|
|
182
|
+
surface: details.surface ?? facts.surface,
|
|
183
|
+
value: details.value ?? facts.value,
|
|
184
|
+
agentName: details.agentName,
|
|
185
|
+
result: decision.approved ? "allow" : "deny",
|
|
186
|
+
resolution: servedResolution(decision),
|
|
187
|
+
origin: null,
|
|
188
|
+
matchedPattern: null,
|
|
189
|
+
forwarding: details.forwarding ?? null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Name how a served ask resolved, reading the decision's own stamp rather than
|
|
195
|
+
* re-deriving it from the outcome: the site that decided already recorded what
|
|
196
|
+
* it was (#726).
|
|
197
|
+
*
|
|
198
|
+
* The grant scope is reported as the human chose it. {@link applyGrantScope}
|
|
199
|
+
* rewrites a whole-serving-session grant to a plain approval on the wire, but
|
|
200
|
+
* that translation is about what the *child* records, not about what was
|
|
201
|
+
* allowed here.
|
|
202
|
+
*/
|
|
203
|
+
function servedResolution(
|
|
204
|
+
decision: PermissionPromptDecision,
|
|
205
|
+
): PermissionDecisionResolution {
|
|
206
|
+
if (decision.decidedBy.kind === "gate_error") {
|
|
207
|
+
return "gate_error";
|
|
208
|
+
}
|
|
209
|
+
if (decision.confirmationUnavailable) {
|
|
210
|
+
return "confirmation_unavailable";
|
|
211
|
+
}
|
|
212
|
+
if (!decision.approved) {
|
|
213
|
+
return "user_denied";
|
|
214
|
+
}
|
|
215
|
+
return decision.state === "approved_for_session" ||
|
|
216
|
+
decision.state === "approved_for_serving_session"
|
|
217
|
+
? "user_approved_for_session"
|
|
218
|
+
: "user_approved";
|
|
219
|
+
}
|
|
220
|
+
|
|
146
221
|
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
|
147
222
|
|
|
148
223
|
/**
|
|
@@ -158,6 +233,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
158
233
|
private readonly logger: DebugReviewLogger;
|
|
159
234
|
private readonly policy: ServingPolicy;
|
|
160
235
|
private readonly escalator: AskEscalator;
|
|
236
|
+
private readonly broadcaster: DecisionBroadcaster;
|
|
161
237
|
private readonly recorder: SessionApprovalRecorder;
|
|
162
238
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
163
239
|
|
|
@@ -166,6 +242,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
166
242
|
this.logger = deps.logger;
|
|
167
243
|
this.policy = deps.policy;
|
|
168
244
|
this.escalator = deps.escalator;
|
|
245
|
+
this.broadcaster = deps.broadcaster;
|
|
169
246
|
this.recorder = deps.recorder;
|
|
170
247
|
this.registry = deps.registry;
|
|
171
248
|
}
|
|
@@ -406,16 +483,35 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
406
483
|
}
|
|
407
484
|
|
|
408
485
|
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
486
|
+
const details = buildForwardedAskDetails(request);
|
|
487
|
+
const decision = await this.escalateAsk(details);
|
|
488
|
+
// Announced before the grant-scope translation and before the response is
|
|
489
|
+
// written: the ask this session broadcast is over once someone here has
|
|
490
|
+
// answered it, whatever becomes of the file the child polls for (#610).
|
|
491
|
+
this.broadcaster.emitDecision(buildServedDecisionEvent(details, decision));
|
|
492
|
+
return decision;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Escalate a forwarded ask to the serving session's selected `Authorizer`,
|
|
497
|
+
* failing closed instead of throwing: an escalation that breaks is nobody's
|
|
498
|
+
* denial, so the node records itself as the decider.
|
|
499
|
+
*
|
|
500
|
+
* Separate from {@link resolveDecision} so the ask's details outlive the
|
|
501
|
+
* call — every record of the served ask is a render over that one object.
|
|
502
|
+
*/
|
|
503
|
+
private async escalateAsk(
|
|
504
|
+
details: PromptPermissionDetails,
|
|
505
|
+
): Promise<PermissionPromptDecision> {
|
|
409
506
|
try {
|
|
410
|
-
return await this.escalator.escalate(
|
|
507
|
+
return await this.escalator.escalate(details);
|
|
411
508
|
} catch (error) {
|
|
412
509
|
const reason = formatUnknownErrorMessage(error);
|
|
413
510
|
logPermissionForwardingError(
|
|
414
511
|
this.logger,
|
|
415
|
-
`Failed to escalate forwarded permission request '${
|
|
512
|
+
`Failed to escalate forwarded permission request '${details.requestId}'`,
|
|
416
513
|
error,
|
|
417
514
|
);
|
|
418
|
-
// Nobody denied this; the escalation broke and the node failed closed.
|
|
419
515
|
return {
|
|
420
516
|
approved: false,
|
|
421
517
|
state: "denied",
|
package/src/decision-reporter.ts
CHANGED
|
@@ -5,14 +5,25 @@ import {
|
|
|
5
5
|
} from "./permission-events";
|
|
6
6
|
import type { SessionLogger } from "./session-logger";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Broadcasts a terminal permission decision on the `permissions:decision`
|
|
10
|
+
* channel.
|
|
11
|
+
*
|
|
12
|
+
* Narrow by design (ISP): a collaborator that only announces an outcome — the
|
|
13
|
+
* serving session answering another session's forwarded request — depends on
|
|
14
|
+
* this rather than on the review-log half it never writes.
|
|
15
|
+
*/
|
|
16
|
+
export interface DecisionBroadcaster {
|
|
17
|
+
emitDecision(event: PermissionDecisionEvent): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Reports a permission gate's outcome to the review log and the decision
|
|
10
22
|
* channel. Groups the two side effects that always travel together:
|
|
11
23
|
* writing a structured review-log entry and broadcasting a decision event.
|
|
12
24
|
*/
|
|
13
|
-
export interface DecisionReporter {
|
|
25
|
+
export interface DecisionReporter extends DecisionBroadcaster {
|
|
14
26
|
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
15
|
-
emitDecision(event: PermissionDecisionEvent): void;
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
|
4
5
|
import { SessionApproval } from "#src/session-approval";
|
|
5
|
-
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
6
|
import type { GateResult } from "./descriptor";
|
|
7
7
|
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
8
8
|
import { accessFactsFromPath } from "./helpers";
|
|
@@ -25,6 +25,7 @@ export function describeBashExternalDirectoryGate(
|
|
|
25
25
|
tcc: ToolCallContext,
|
|
26
26
|
bashProgram: BashProgram | null,
|
|
27
27
|
resolver: ScopedPermissionResolver,
|
|
28
|
+
normalizer: PathNormalizer,
|
|
28
29
|
): GateResult {
|
|
29
30
|
if (!bashProgram) return null;
|
|
30
31
|
const command = bashProgram.commandText();
|
|
@@ -94,7 +95,9 @@ export function describeBashExternalDirectoryGate(
|
|
|
94
95
|
matchedPattern: preCheck.matchedPattern,
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
const patterns =
|
|
98
|
+
const patterns = uncoveredEntries.map(({ path }) =>
|
|
99
|
+
normalizer.approvalPatternFor(path),
|
|
100
|
+
);
|
|
98
101
|
|
|
99
102
|
return {
|
|
100
103
|
surface: "external_directory",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
3
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
3
4
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
5
|
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
|
5
6
|
import { SessionApproval } from "#src/session-approval";
|
|
6
|
-
import { deriveApprovalPattern } from "#src/session-rules";
|
|
7
7
|
import type { PermissionCheckResult } from "#src/types";
|
|
8
8
|
import { pickMostRestrictive } from "./candidate-check";
|
|
9
9
|
import type { GateResult } from "./descriptor";
|
|
@@ -33,6 +33,7 @@ export function describeBashPathGate(
|
|
|
33
33
|
tcc: ToolCallContext,
|
|
34
34
|
bashProgram: BashProgram | null,
|
|
35
35
|
resolver: ScopedPermissionResolver,
|
|
36
|
+
normalizer: PathNormalizer,
|
|
36
37
|
): GateResult {
|
|
37
38
|
if (!bashProgram) return null;
|
|
38
39
|
const command = bashProgram.commandText();
|
|
@@ -120,7 +121,7 @@ export function describeBashPathGate(
|
|
|
120
121
|
// Derive the pattern from the lexical absolute form (the cd-aware resolved
|
|
121
122
|
// path), so it matches the values a later call produces. For an unknown base
|
|
122
123
|
// (`forLiteral`) `value()` is the raw token.
|
|
123
|
-
const pattern =
|
|
124
|
+
const pattern = normalizer.approvalPatternFor(worstEntry.path);
|
|
124
125
|
const payload = buildPathAskPayload({
|
|
125
126
|
toolName: tcc.toolName,
|
|
126
127
|
pathValue: worstToken,
|
|
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
|
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
|
|
5
5
|
import { SessionApproval } from "#src/session-approval";
|
|
6
|
-
import { deriveApprovalPattern } from "#src/session-rules";
|
|
7
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
8
7
|
import type { GateResult } from "./descriptor";
|
|
9
8
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
@@ -78,7 +77,7 @@ export function describeExternalDirectoryGate(
|
|
|
78
77
|
resolver,
|
|
79
78
|
tcc.agentName ?? undefined,
|
|
80
79
|
);
|
|
81
|
-
const pattern =
|
|
80
|
+
const pattern = normalizer.approvalPatternFor(accessPath);
|
|
82
81
|
|
|
83
82
|
const payload = buildExternalDirectoryAskPayload({
|
|
84
83
|
toolName: tcc.toolName,
|
|
@@ -3,7 +3,6 @@ import type { PathNormalizer } from "#src/path-normalizer";
|
|
|
3
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import { buildPathAskPayload } from "#src/presentation/path-ask-payload";
|
|
5
5
|
import { SessionApproval } from "#src/session-approval";
|
|
6
|
-
import { deriveApprovalPattern } from "#src/session-rules";
|
|
7
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
8
7
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
9
8
|
import { accessFactsFromPath } from "./helpers";
|
|
@@ -46,7 +45,7 @@ export function describePathGate(
|
|
|
46
45
|
|
|
47
46
|
// Derive the approval pattern from the lexical absolute form so it matches
|
|
48
47
|
// the policy values a later call produces.
|
|
49
|
-
const pattern =
|
|
48
|
+
const pattern = normalizer.approvalPatternFor(accessPath);
|
|
50
49
|
|
|
51
50
|
const payload = buildPathAskPayload({
|
|
52
51
|
toolName: tcc.toolName,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
1
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
2
|
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
|
|
4
3
|
import {
|
|
@@ -24,7 +23,7 @@ import { describeExternalDirectoryGate } from "./external-directory";
|
|
|
24
23
|
import { describePathGate } from "./path";
|
|
25
24
|
import type { GateRunner } from "./runner";
|
|
26
25
|
import { describeSkillReadGate } from "./skill-read";
|
|
27
|
-
import { describeToolGate } from "./tool";
|
|
26
|
+
import { describeToolGate, type ToolPathAccess } from "./tool";
|
|
28
27
|
import type { GateOutcome, ToolCallContext } from "./types";
|
|
29
28
|
|
|
30
29
|
/**
|
|
@@ -115,10 +114,16 @@ export class ToolCallGatePipeline {
|
|
|
115
114
|
normalizer,
|
|
116
115
|
this.customExtractors,
|
|
117
116
|
),
|
|
118
|
-
() =>
|
|
119
|
-
|
|
117
|
+
() =>
|
|
118
|
+
describeBashExternalDirectoryGate(
|
|
119
|
+
tcc,
|
|
120
|
+
bashProgram,
|
|
121
|
+
this.resolver,
|
|
122
|
+
normalizer,
|
|
123
|
+
),
|
|
124
|
+
() => describeBashPathGate(tcc, bashProgram, this.resolver, normalizer),
|
|
120
125
|
() => {
|
|
121
|
-
const { toolCheck,
|
|
126
|
+
const { toolCheck, pathAccess } = this.resolvePerToolCheck(
|
|
122
127
|
tcc,
|
|
123
128
|
shell,
|
|
124
129
|
bashProgram,
|
|
@@ -128,7 +133,7 @@ export class ToolCallGatePipeline {
|
|
|
128
133
|
tcc,
|
|
129
134
|
toolCheck,
|
|
130
135
|
formatter,
|
|
131
|
-
|
|
136
|
+
pathAccess,
|
|
132
137
|
shell,
|
|
133
138
|
);
|
|
134
139
|
toolDescriptor.preCheck = toolCheck;
|
|
@@ -153,15 +158,16 @@ export class ToolCallGatePipeline {
|
|
|
153
158
|
* #502); every other tool (and a path-bearing tool with no path) keeps the
|
|
154
159
|
* raw `tool` intent the manager normalizes.
|
|
155
160
|
*
|
|
156
|
-
* Returns the
|
|
157
|
-
*
|
|
161
|
+
* Returns the resolved path alongside the check, already paired with the
|
|
162
|
+
* session scope approving it grants — derived here, where the normalizer
|
|
163
|
+
* lives, rather than inside the gate (#655).
|
|
158
164
|
*/
|
|
159
165
|
private resolvePerToolCheck(
|
|
160
166
|
tcc: ToolCallContext,
|
|
161
167
|
shell: ShellInvocation | null,
|
|
162
168
|
bashProgram: BashProgram | null,
|
|
163
169
|
normalizer: PathNormalizer,
|
|
164
|
-
): { toolCheck: PermissionCheckResult;
|
|
170
|
+
): { toolCheck: PermissionCheckResult; pathAccess?: ToolPathAccess } {
|
|
165
171
|
if (shell) {
|
|
166
172
|
if (bashProgram) {
|
|
167
173
|
return {
|
|
@@ -190,7 +196,10 @@ export class ToolCallGatePipeline {
|
|
|
190
196
|
if (filePath !== null) {
|
|
191
197
|
const accessPath = normalizer.forPath(filePath);
|
|
192
198
|
return {
|
|
193
|
-
|
|
199
|
+
pathAccess: {
|
|
200
|
+
path: accessPath,
|
|
201
|
+
approvalPattern: normalizer.approvalPatternFor(accessPath),
|
|
202
|
+
},
|
|
194
203
|
toolCheck: this.resolver.resolve({
|
|
195
204
|
kind: "access-path",
|
|
196
205
|
surface: tcc.toolName,
|
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
classifyToolKind,
|
|
6
6
|
type ShellInvocation,
|
|
7
7
|
} from "#src/access-intent/tool-kind";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
suggestPathSessionPattern,
|
|
10
|
+
suggestSessionPattern,
|
|
11
|
+
} from "#src/pattern-suggest";
|
|
9
12
|
import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
|
|
10
13
|
import { SessionApproval } from "#src/session-approval";
|
|
11
14
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
@@ -18,18 +21,30 @@ import {
|
|
|
18
21
|
} from "./helpers";
|
|
19
22
|
import type { ToolCallContext } from "./types";
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A path-bearing tool call's resolved path, paired with the session scope
|
|
26
|
+
* approving it would grant.
|
|
27
|
+
*
|
|
28
|
+
* The pattern is derived by the pipeline's `PathNormalizer`, which owns the
|
|
29
|
+
* session's `PathFlavor`, rather than re-derived here from `path.value()` — so
|
|
30
|
+
* the gate carries the platform's separator semantics without holding them
|
|
31
|
+
* (#655).
|
|
32
|
+
*/
|
|
33
|
+
export interface ToolPathAccess {
|
|
34
|
+
readonly path: AccessPath;
|
|
35
|
+
readonly approvalPattern: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/**
|
|
22
39
|
* Derive the value used for session-approval pattern suggestions.
|
|
23
40
|
*
|
|
24
|
-
* Bash → command string; MCP → qualified target;
|
|
25
|
-
* path-bearing
|
|
26
|
-
*
|
|
27
|
-
* others (or a path-bearing tool with no path) → catch-all wildcard.
|
|
41
|
+
* Bash → command string; MCP → qualified target; everything else → catch-all
|
|
42
|
+
* wildcard. A path-bearing tool that resolved a path never reaches here — its
|
|
43
|
+
* suggestion comes from the already-derived {@link ToolPathAccess} pattern.
|
|
28
44
|
*/
|
|
29
45
|
function deriveSuggestionValue(
|
|
30
46
|
toolName: string,
|
|
31
47
|
check: PermissionCheckResult,
|
|
32
|
-
accessPath?: AccessPath,
|
|
33
48
|
): string {
|
|
34
49
|
switch (classifyToolKind(toolName)) {
|
|
35
50
|
case "bash":
|
|
@@ -37,7 +52,7 @@ function deriveSuggestionValue(
|
|
|
37
52
|
case "mcp":
|
|
38
53
|
return check.target ?? "mcp";
|
|
39
54
|
default:
|
|
40
|
-
return
|
|
55
|
+
return "*";
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
|
|
@@ -51,7 +66,7 @@ export function describeToolGate(
|
|
|
51
66
|
tcc: ToolCallContext,
|
|
52
67
|
check: PermissionCheckResult,
|
|
53
68
|
formatter: ToolPreviewFormatter,
|
|
54
|
-
|
|
69
|
+
pathAccess?: ToolPathAccess,
|
|
55
70
|
shell?: ShellInvocation | null,
|
|
56
71
|
): GateDescriptor {
|
|
57
72
|
// A shell invocation (native `bash` or an aliased shell tool) is gated on the
|
|
@@ -67,10 +82,12 @@ export function describeToolGate(
|
|
|
67
82
|
);
|
|
68
83
|
|
|
69
84
|
// Compute session approval suggestion for the "for this session" option.
|
|
70
|
-
const suggestion =
|
|
71
|
-
gateSurface,
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
const suggestion = pathAccess
|
|
86
|
+
? suggestPathSessionPattern(gateSurface, pathAccess.approvalPattern)
|
|
87
|
+
: suggestSessionPattern(
|
|
88
|
+
gateSurface,
|
|
89
|
+
deriveSuggestionValue(gateSurface, check),
|
|
90
|
+
);
|
|
74
91
|
|
|
75
92
|
const payload = buildToolAskPayload({
|
|
76
93
|
check,
|
|
@@ -89,8 +106,8 @@ export function describeToolGate(
|
|
|
89
106
|
|
|
90
107
|
// A path-bearing tool carries the AccessPath's alias set; every other surface
|
|
91
108
|
// (bash command, MCP target, plain tool) carries its already-portable value.
|
|
92
|
-
const accessIntent =
|
|
93
|
-
? accessFactsFromPath(gateSurface,
|
|
109
|
+
const accessIntent = pathAccess
|
|
110
|
+
? accessFactsFromPath(gateSurface, pathAccess.path)
|
|
94
111
|
: accessFactsFromValue(gateSurface, decisionValue);
|
|
95
112
|
|
|
96
113
|
return {
|
|
@@ -66,7 +66,12 @@ export function createFailClosedToolCall(
|
|
|
66
66
|
* The block below this must be reached: the SDK does not catch a throwing
|
|
67
67
|
* handler, so an exception escaping the recording work would leave the command
|
|
68
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
|
|
69
|
+
* have come from anywhere in the pipeline, so no gate's id is available — and
|
|
70
|
+
* shared by both records, so the terminal broadcast joins the review entry.
|
|
71
|
+
*
|
|
72
|
+
* The broadcast carries what the raw event yields and nothing inferred: no
|
|
73
|
+
* rule won, and the boundary holds no session, so `origin`, `matchedPattern`,
|
|
74
|
+
* and `agentName` are `null` (#753).
|
|
70
75
|
*/
|
|
71
76
|
function recordGateError(
|
|
72
77
|
reporter: DecisionReporter,
|
|
@@ -77,15 +82,28 @@ function recordGateError(
|
|
|
77
82
|
try {
|
|
78
83
|
audit.recordError();
|
|
79
84
|
const reason = errorMessage(error);
|
|
85
|
+
const requestId = createPermissionRequestId();
|
|
86
|
+
const toolName = bestEffortToolName(event);
|
|
87
|
+
const command = bestEffortCommand(event);
|
|
80
88
|
reporter.writeReviewLog("permission_request.blocked", {
|
|
81
|
-
requestId
|
|
82
|
-
toolName
|
|
83
|
-
command
|
|
89
|
+
requestId,
|
|
90
|
+
toolName,
|
|
91
|
+
command,
|
|
84
92
|
resolution: "gate_error",
|
|
85
93
|
error: reason,
|
|
86
94
|
// The boundary decided, by failing closed -- no rule and no human did.
|
|
87
95
|
decidedBy: { kind: "gate_error", reason },
|
|
88
96
|
});
|
|
97
|
+
reporter.emitDecision({
|
|
98
|
+
requestId,
|
|
99
|
+
surface: toolName,
|
|
100
|
+
value: command ?? toolName,
|
|
101
|
+
result: "deny",
|
|
102
|
+
resolution: "gate_error",
|
|
103
|
+
origin: null,
|
|
104
|
+
agentName: null,
|
|
105
|
+
matchedPattern: null,
|
|
106
|
+
});
|
|
89
107
|
} catch {
|
|
90
108
|
// The block is the guarantee; its bookkeeping is not.
|
|
91
109
|
}
|
package/src/index.ts
CHANGED
|
@@ -181,11 +181,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
181
181
|
),
|
|
182
182
|
};
|
|
183
183
|
|
|
184
|
+
// Constructed here rather than beside the gate runner below: the serving
|
|
185
|
+
// side broadcasts its own decisions, so both readers share one reporter over
|
|
186
|
+
// this session's event bus.
|
|
187
|
+
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
188
|
+
|
|
184
189
|
const requestServer = new ForwardedRequestServer({
|
|
185
190
|
forwardingDir: paths.forwardingDir,
|
|
186
191
|
logger,
|
|
187
192
|
policy: servingPolicy,
|
|
188
193
|
escalator: authorizerSelection,
|
|
194
|
+
// The forwarded ask's own gate lives in the requesting session, so the
|
|
195
|
+
// serving side announces the terminal decision on this session's bus.
|
|
196
|
+
broadcaster: reporter,
|
|
189
197
|
// Records a whole-session grant into the same SessionRules the resolver and
|
|
190
198
|
// gate runner read, so a serving-scope grant governs the parent and future
|
|
191
199
|
// forwarded resolutions.
|
|
@@ -275,7 +283,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
275
283
|
},
|
|
276
284
|
);
|
|
277
285
|
|
|
278
|
-
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
279
286
|
const gateRunner = new GateRunner(
|
|
280
287
|
resolver,
|
|
281
288
|
sessionRules,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derive the wildcard glob to record when a user approves an accessed path for
|
|
5
|
+
* the session: the path's directory scope, with `*` appended.
|
|
6
|
+
*
|
|
7
|
+
* The scope is the value up to and including its last path separator, so the
|
|
8
|
+
* pattern is spelled with the separator the value itself carries. That matters
|
|
9
|
+
* on a win32 host, where Git Bash tokens are POSIX-shaped while Node's own
|
|
10
|
+
* `sep` is a backslash: deriving `/tmp/logs\*` from `/tmp/logs/` widens the
|
|
11
|
+
* grant to the parent directory once the `windowsSeparators` fold (#653)
|
|
12
|
+
* normalizes both operands. A value carrying no separator falls back to the
|
|
13
|
+
* current directory, which is what callers see only if they skipped resolving
|
|
14
|
+
* the path to its absolute form first (#438).
|
|
15
|
+
*
|
|
16
|
+
* The platform's separator alphabet arrives as an injected {@link PathFlavor},
|
|
17
|
+
* never an ambient `node:path` read, so win32 derivation is decidable — and
|
|
18
|
+
* testable — on a POSIX host (#655).
|
|
19
|
+
*/
|
|
20
|
+
export function deriveApprovalPattern(
|
|
21
|
+
pathValue: string,
|
|
22
|
+
flavor: PathFlavor,
|
|
23
|
+
): string {
|
|
24
|
+
const lastSeparator = flavor.lastSeparatorIndex(pathValue);
|
|
25
|
+
if (lastSeparator < 0) return `.${flavor.impl.sep}*`;
|
|
26
|
+
return `${pathValue.slice(0, lastSeparator + 1)}*`;
|
|
27
|
+
}
|
package/src/path/path-flavor.ts
CHANGED
|
@@ -17,7 +17,8 @@ import type { WildcardMatchOptions } from "#src/wildcard-matcher";
|
|
|
17
17
|
* fold or separator fold is a silent bypass (the #382 / #508 class). `PathFlavor`
|
|
18
18
|
* captures that mapping once so the leaves consume the resolved capability
|
|
19
19
|
* instead of re-interpreting a raw `NodeJS.Platform` string. It owns platform
|
|
20
|
-
* **semantics** — syntax ({@link hasPathSeparator}
|
|
20
|
+
* **semantics** — syntax ({@link hasPathSeparator} /
|
|
21
|
+
* {@link lastSeparatorIndex}), token shape
|
|
21
22
|
* ({@link bashTokenShape}), and the equivalence relation ({@link fold} /
|
|
22
23
|
* {@link comparable} / {@link isWithin} / {@link matchOptions}); domain policy
|
|
23
24
|
* (lexical cleanup, alias generation, safe-system-path exclusions, rule
|
|
@@ -50,6 +51,15 @@ export interface PathFlavor {
|
|
|
50
51
|
* POSIX; `/` or `\` on win32 (where a backslash is a separator, #520).
|
|
51
52
|
*/
|
|
52
53
|
hasPathSeparator(token: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Index of the last path separator in `value`, or `-1` when it holds none.
|
|
56
|
+
*
|
|
57
|
+
* Reads the same separator alphabet as {@link hasPathSeparator}, so a caller
|
|
58
|
+
* that must split a path at its directory boundary uses the separator the
|
|
59
|
+
* value was written with rather than this platform's default `sep` — the two
|
|
60
|
+
* differ for a Git Bash token on a win32 host (`/dev/null`, `/tmp/logs/`).
|
|
61
|
+
*/
|
|
62
|
+
lastSeparatorIndex(value: string): number;
|
|
53
63
|
/**
|
|
54
64
|
* The MSYS/Git-Bash interpretation of a bash-command token. On win32 this
|
|
55
65
|
* carries device / drive-mount / posix-absolute / plain semantics; on POSIX
|
|
@@ -60,6 +70,8 @@ export interface PathFlavor {
|
|
|
60
70
|
|
|
61
71
|
class PlatformPathFlavor implements PathFlavor {
|
|
62
72
|
readonly matchOptions: WildcardMatchOptions | undefined;
|
|
73
|
+
/** Every separator spelling this platform recognizes, the one alphabet both separator answers read. */
|
|
74
|
+
private readonly separators: readonly string[];
|
|
63
75
|
|
|
64
76
|
constructor(
|
|
65
77
|
readonly impl: PlatformPath,
|
|
@@ -68,6 +80,7 @@ class PlatformPathFlavor implements PathFlavor {
|
|
|
68
80
|
this.matchOptions = windows
|
|
69
81
|
? { caseInsensitive: true, windowsSeparators: true }
|
|
70
82
|
: undefined;
|
|
83
|
+
this.separators = windows ? ["/", "\\"] : ["/"];
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
fold(value: string): string {
|
|
@@ -91,7 +104,14 @@ class PlatformPathFlavor implements PathFlavor {
|
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
hasPathSeparator(token: string): boolean {
|
|
94
|
-
return
|
|
107
|
+
return this.lastSeparatorIndex(token) >= 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lastSeparatorIndex(value: string): number {
|
|
111
|
+
return this.separators.reduce(
|
|
112
|
+
(last, separator) => Math.max(last, value.lastIndexOf(separator)),
|
|
113
|
+
-1,
|
|
114
|
+
);
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
bashTokenShape(token: string): BashTokenShape {
|
package/src/path-normalizer.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
normalizePathForComparison,
|
|
9
9
|
normalizePathPolicyLiteral,
|
|
10
10
|
} from "./access-intent/path-normalization";
|
|
11
|
+
import { deriveApprovalPattern } from "./path/approval-pattern";
|
|
11
12
|
import { isPathOutsideWorkingDirectory } from "./path/path-containment";
|
|
12
13
|
import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
|
|
13
14
|
|
|
@@ -99,6 +100,20 @@ export class PathNormalizer {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The session-approval glob for an accessed path: its directory scope plus
|
|
105
|
+
* `*`, derived through the baked flavor.
|
|
106
|
+
*
|
|
107
|
+
* Takes the already-built {@link AccessPath} — the lexical form is what a
|
|
108
|
+
* later tool call is matched on, so the pattern must be derived from the
|
|
109
|
+
* same representation the decision displayed (#438). Deriving it here rather
|
|
110
|
+
* than at each gate keeps the platform's separator alphabet with the object
|
|
111
|
+
* that owns the flavor, instead of an ambient `node:path` read (#655).
|
|
112
|
+
*/
|
|
113
|
+
approvalPatternFor(accessPath: AccessPath): string {
|
|
114
|
+
return deriveApprovalPattern(accessPath.value(), this.flavor);
|
|
115
|
+
}
|
|
116
|
+
|
|
102
117
|
/** Platform-aware absoluteness (`win32` vs `posix` rules). */
|
|
103
118
|
isAbsolute(pathValue: string): boolean {
|
|
104
119
|
return this.flavor.impl.isAbsolute(pathValue);
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
|
|
2
2
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
3
|
-
import { deriveApprovalPattern } from "./session-rules";
|
|
4
3
|
|
|
5
4
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
6
5
|
export interface SessionApprovalSuggestion {
|
|
@@ -115,14 +114,16 @@ function buildLabel(pattern: string, surface: string): string {
|
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
/**
|
|
118
|
-
* Suggest a session-approval pattern
|
|
117
|
+
* Suggest a session-approval pattern from a surface's own value vocabulary —
|
|
118
|
+
* a bash command, an MCP target, a skill name.
|
|
119
119
|
*
|
|
120
120
|
* Returns a `SessionApprovalSuggestion` with the surface, the wildcard pattern
|
|
121
|
-
* to store in `SessionRules`, and a human-readable dialog label.
|
|
121
|
+
* to store in `SessionRules`, and a human-readable dialog label. Any surface
|
|
122
|
+
* with no vocabulary of its own falls back to the catch-all wildcard, which is
|
|
123
|
+
* also what a path-bearing tool invoked without a path resolves to.
|
|
122
124
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* matches the policy values a later tool call produces.
|
|
125
|
+
* A path surface goes through {@link suggestPathSessionPattern} instead: its
|
|
126
|
+
* pattern is a path-language product, and this module holds no path semantics.
|
|
126
127
|
*/
|
|
127
128
|
export function suggestSessionPattern(
|
|
128
129
|
surface: string,
|
|
@@ -140,22 +141,30 @@ export function suggestSessionPattern(
|
|
|
140
141
|
case "skill":
|
|
141
142
|
pattern = value;
|
|
142
143
|
break;
|
|
143
|
-
case "external_directory":
|
|
144
|
-
pattern = deriveApprovalPattern(value);
|
|
145
|
-
break;
|
|
146
|
-
case "path":
|
|
147
|
-
pattern = deriveApprovalPattern(value);
|
|
148
|
-
break;
|
|
149
144
|
default:
|
|
150
|
-
//
|
|
151
|
-
if (PATH_BEARING_TOOLS.has(surface) && value !== "*") {
|
|
152
|
-
pattern = deriveApprovalPattern(value);
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
// Extension tools / fallback.
|
|
145
|
+
// Extension tools, and path-bearing tools invoked without a path.
|
|
156
146
|
pattern = "*";
|
|
157
147
|
break;
|
|
158
148
|
}
|
|
159
149
|
|
|
160
150
|
return { surface, pattern, label: buildLabel(pattern, surface) };
|
|
161
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build the suggestion for a path surface from a pattern the caller already
|
|
155
|
+
* derived through its `PathNormalizer` (#655).
|
|
156
|
+
*
|
|
157
|
+
* The derivation belongs to the normalizer, which owns the session's
|
|
158
|
+
* `PathFlavor`; this module labels the result and must not re-interpret the
|
|
159
|
+
* separators it is handed.
|
|
160
|
+
*/
|
|
161
|
+
export function suggestPathSessionPattern(
|
|
162
|
+
surface: string,
|
|
163
|
+
approvalPattern: string,
|
|
164
|
+
): SessionApprovalSuggestion {
|
|
165
|
+
return {
|
|
166
|
+
surface,
|
|
167
|
+
pattern: approvalPattern,
|
|
168
|
+
label: buildLabel(approvalPattern, surface),
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/permission-events.ts
CHANGED
|
@@ -110,7 +110,9 @@ export type PermissionDecisionResolution =
|
|
|
110
110
|
| "user_approved_for_session"
|
|
111
111
|
| "user_denied"
|
|
112
112
|
| "auto_approved"
|
|
113
|
-
| "confirmation_unavailable"
|
|
113
|
+
| "confirmation_unavailable"
|
|
114
|
+
/** The gate threw, or an escalation failed, and the request was blocked. */
|
|
115
|
+
| "gate_error";
|
|
114
116
|
|
|
115
117
|
/** Payload emitted on `permissions:decision`. */
|
|
116
118
|
export interface PermissionDecisionEvent {
|
|
@@ -134,6 +136,16 @@ export interface PermissionDecisionEvent {
|
|
|
134
136
|
agentName: string | null;
|
|
135
137
|
/** Matched pattern from the winning rule (when available). */
|
|
136
138
|
matchedPattern: string | null;
|
|
139
|
+
/**
|
|
140
|
+
* Forwarding context for a decision this session made while serving another
|
|
141
|
+
* session's forwarded request; absent on an ordinary local decision.
|
|
142
|
+
*
|
|
143
|
+
* The same `ForwardedPromptContext` the request's `permissions:ui_prompt`
|
|
144
|
+
* carried, so a consumer that never saw the prompt can still tell a served
|
|
145
|
+
* ask from a local one. Requester identity beyond it — the requester's cwd
|
|
146
|
+
* and principal — stays off the bus.
|
|
147
|
+
*/
|
|
148
|
+
forwarding?: ForwardedPromptContext | null;
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
// ── Emit helpers ───────────────────────────────────────────────────────────
|
package/src/session-rules.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { dirname, sep } from "node:path";
|
|
2
|
-
|
|
3
1
|
import type { Ruleset } from "./rule";
|
|
4
2
|
import type { SessionApproval } from "./session-approval";
|
|
5
3
|
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
@@ -48,32 +46,3 @@ export class SessionRules implements SessionApprovalRecorder {
|
|
|
48
46
|
this.rules = [];
|
|
49
47
|
}
|
|
50
48
|
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Derive the wildcard glob pattern to approve from a normalized path.
|
|
54
|
-
*
|
|
55
|
-
* Returns `<parent-dir>/*` so that `evaluate()` / `wildcardMatch()` matches
|
|
56
|
-
* all paths under the approved directory — identical semantics to the former
|
|
57
|
-
* `SessionApprovalCache` prefix matching, using the unified wildcard engine.
|
|
58
|
-
*
|
|
59
|
-
* For paths that already end with a separator (directories), the separator
|
|
60
|
-
* is treated as the directory boundary and `*` is appended directly.
|
|
61
|
-
*
|
|
62
|
-
* The path is expected to be the canonical (cwd-resolved, absolute) form used
|
|
63
|
-
* for policy matching, so the derived pattern matches the same policy values a
|
|
64
|
-
* later tool call produces. Callers that hold a working directory resolve the
|
|
65
|
-
* path to that form first; the function itself stays free of cwd state.
|
|
66
|
-
*/
|
|
67
|
-
export function deriveApprovalPattern(normalizedPath: string): string {
|
|
68
|
-
// If the path already ends with a separator, it's a directory — glob its contents.
|
|
69
|
-
if (normalizedPath.endsWith(sep)) {
|
|
70
|
-
return `${normalizedPath}*`;
|
|
71
|
-
}
|
|
72
|
-
const dir = dirname(normalizedPath);
|
|
73
|
-
if (dir === normalizedPath) {
|
|
74
|
-
// Root path — dirname('/') === '/'
|
|
75
|
-
return `${dir}*`;
|
|
76
|
-
}
|
|
77
|
-
const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
78
|
-
return `${prefix}*`;
|
|
79
|
-
}
|