@gotgenes/pi-permission-system 29.3.0 → 30.1.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 +28 -0
- package/README.md +1 -0
- package/dist/public.d.ts +47 -7
- package/docs/configuration.md +8 -0
- package/docs/cross-extension-api.md +21 -5
- package/docs/migration/0810-per-pattern-approval-surfaces.md +76 -0
- package/package.json +1 -1
- package/src/approval-grant.ts +13 -0
- package/src/authority/forwarded-request-server.ts +3 -8
- package/src/authority/forwarding-io.ts +33 -9
- package/src/authority/local-user-authorizer.ts +4 -4
- package/src/authority/permission-forwarding.ts +11 -5
- package/src/handlers/before-agent-start.ts +8 -8
- package/src/handlers/gates/bash-external-directory.ts +10 -29
- package/src/handlers/gates/runner.ts +2 -2
- package/src/permission-gate.ts +16 -11
- package/src/permission-manager.ts +20 -0
- package/src/permission-resolver.ts +9 -0
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +49 -0
- package/src/service.ts +24 -2
- package/src/session-approval.ts +20 -30
- package/src/session-rules.ts +7 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [30.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.0.0...pi-permission-system-v30.1.0) (2026-09-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** publish isToolFullyDenied for cross-extension tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([4345b19](https://github.com/gotgenes/pi-packages/commit/4345b199e5a33427e47bb80f9c16cb78fb1e7fbf)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** stop hiding a tool whose surface has a reachable non-deny rule ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([ce46b6c](https://github.com/gotgenes/pi-packages/commit/ce46b6c7b9f4c901d6a035f3be6e334f77a76e95)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** describe tool exposure as surface reachability ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([71d9696](https://github.com/gotgenes/pi-packages/commit/71d96969e5c33ecc0f38b0a43b0bdaf267622000))
|
|
22
|
+
* **pi-permission-system:** stop recommending getToolPermission for tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([fdf0a38](https://github.com/gotgenes/pi-packages/commit/fdf0a383d5419eaded4b6277ead8264e23aa281d))
|
|
23
|
+
|
|
24
|
+
## [30.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.3.0...pi-permission-system-v30.0.0) (2026-09-02)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** **breaking:** record a session approval's surface per pattern ([499444d](https://github.com/gotgenes/pi-packages/commit/499444df5a21e289409cbf3b360033a13d0eba56)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
|
|
30
|
+
* **pi-permission-system:** grant each external path only the direction its command proved ([aa38c74](https://github.com/gotgenes/pi-packages/commit/aa38c746bf9b82ed0dc96217cf4789aae9f090cd)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** document per-pattern approval surfaces and mark Phase 14 Step 10 ([5511450](https://github.com/gotgenes/pi-packages/commit/551145024364cc794b3c48256362d69971aa69de)), closes [#810](https://github.com/gotgenes/pi-packages/issues/810)
|
|
35
|
+
|
|
8
36
|
## [29.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.2.0...pi-permission-system-v29.3.0) (2026-09-02)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -210,6 +210,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
|
|
|
210
210
|
| [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
|
|
211
211
|
| [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
|
|
212
212
|
| [docs/migration/0796-remove-process-root-slot.md](docs/migration/0796-remove-process-root-slot.md) | Process-root slot removed (breaking) — `getRootPermissionsService()` and its publish/unpublish pair are gone |
|
|
213
|
+
| [docs/migration/0810-per-pattern-approval-surfaces.md](docs/migration/0810-per-pattern-approval-surfaces.md) | Per-pattern approval surfaces (breaking) — `ForwardedSessionApproval.grants` replaces `surface` + `patterns` |
|
|
213
214
|
|
|
214
215
|
## Development
|
|
215
216
|
|
package/dist/public.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* One session-approval grant: a wildcard pattern approved on one surface.
|
|
5
|
+
*
|
|
6
|
+
* A gate proves a direction per accessed path (ADR 0013 §7), so an ask whose
|
|
7
|
+
* paths disagree records a surface per pattern rather than one for all of them
|
|
8
|
+
* (#810). This lives in its own module because both the {@link SessionApproval}
|
|
9
|
+
* value object and the forwarded wire type name it, and those two already
|
|
10
|
+
* import in one direction.
|
|
11
|
+
*/
|
|
12
|
+
interface ApprovalGrant {
|
|
13
|
+
readonly surface: string;
|
|
14
|
+
readonly pattern: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
|
|
4
18
|
/** A permission decision. */
|
|
5
19
|
type PermissionState = z.infer<typeof permissionStateSchema>;
|
|
@@ -316,16 +330,21 @@ interface PermissionDecisionEvent {
|
|
|
316
330
|
|
|
317
331
|
/**
|
|
318
332
|
* The child's session-approval suggestion, relayed to the serving node so a
|
|
319
|
-
* human who grants "the whole session" records the same
|
|
320
|
-
*
|
|
333
|
+
* human who grants "the whole session" records the same grants the child would
|
|
334
|
+
* have recorded locally.
|
|
321
335
|
*
|
|
322
336
|
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
|
323
337
|
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
|
324
|
-
* from it via `SessionApproval.
|
|
338
|
+
* from it via `SessionApproval.forGrants`.
|
|
339
|
+
*
|
|
340
|
+
* Each grant carries its own surface (#810). The pre-#810 shape — one
|
|
341
|
+
* `surface` plus a `patterns` list — is rejected by the reader rather than
|
|
342
|
+
* normalized, so a version-skewed pair drops the suggestion and the serving
|
|
343
|
+
* dialog offers no whole-session scope; the requesting child still records its
|
|
344
|
+
* own grant, so the failure is narrow in both directions.
|
|
325
345
|
*/
|
|
326
346
|
interface ForwardedSessionApproval {
|
|
327
|
-
|
|
328
|
-
patterns: readonly string[];
|
|
347
|
+
grants: readonly ApprovalGrant[];
|
|
329
348
|
}
|
|
330
349
|
/**
|
|
331
350
|
* The child-fixed facts a gate emits: the surface it evaluated and the match
|
|
@@ -512,13 +531,17 @@ interface PermissionQuery {
|
|
|
512
531
|
*/
|
|
513
532
|
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
|
|
514
533
|
/**
|
|
515
|
-
* Query
|
|
516
|
-
* creating a child session.
|
|
534
|
+
* Query a surface's catch-all permission state — its blanket policy.
|
|
517
535
|
*
|
|
518
536
|
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
519
537
|
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
520
538
|
* use `checkPermission` for runtime invocation gates.
|
|
521
539
|
*
|
|
540
|
+
* This is **not** the question to ask when pre-filtering a tool list: a
|
|
541
|
+
* partially permissive surface such as `bash: {"*": "deny", "git *": "ask"}`
|
|
542
|
+
* answers `"deny"` here while `git status` would still be asked about. Use
|
|
543
|
+
* {@link PermissionsService.isToolFullyDenied} for that.
|
|
544
|
+
*
|
|
522
545
|
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
523
546
|
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
524
547
|
*/
|
|
@@ -538,6 +561,23 @@ interface PermissionQuery {
|
|
|
538
561
|
* rules internally.
|
|
539
562
|
*/
|
|
540
563
|
interface PermissionsService extends PermissionQuery {
|
|
564
|
+
/**
|
|
565
|
+
* Whether every value under a tool's surface resolves to `deny`.
|
|
566
|
+
*
|
|
567
|
+
* This is the question to ask before withholding a tool from a child
|
|
568
|
+
* session's tool list, and it is not `getToolPermission`: that reports the
|
|
569
|
+
* surface's own catch-all, so a partially permissive surface such as
|
|
570
|
+
* `bash: {"*": "deny", "git *": "ask"}` reads as `"deny"` while `git status`
|
|
571
|
+
* would still be asked about.
|
|
572
|
+
*
|
|
573
|
+
* Rule ordering is honored (last-match-wins), so an exception written *after*
|
|
574
|
+
* a `deny` catch-all keeps the tool reachable and one written *before* it
|
|
575
|
+
* does not.
|
|
576
|
+
*
|
|
577
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
578
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
579
|
+
*/
|
|
580
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
541
581
|
/**
|
|
542
582
|
* Register a custom preview formatter for a specific tool name.
|
|
543
583
|
*
|
package/docs/configuration.md
CHANGED
|
@@ -295,6 +295,10 @@ A string value is a catch-all for that surface.
|
|
|
295
295
|
Unknown or absent tools are not required in the config.
|
|
296
296
|
If a tool is not registered at runtime, this extension blocks it before permission checks run.
|
|
297
297
|
|
|
298
|
+
A tool is withheld from the model entirely only when **every** pattern configured under its surface resolves to `deny`.
|
|
299
|
+
So `"bash": "deny"` hides the tool, while `"bash": { "*": "deny", "git *": "ask" }` keeps it visible — the agent can attempt a `git` command and be prompted, and everything else is denied at the gate.
|
|
300
|
+
Ordering follows the same last-match-wins rule as every other lookup: an exception written *after* the `deny` catch-all is reachable, while one written *before* it is shadowed and the tool is hidden.
|
|
301
|
+
|
|
298
302
|
#### Path Patterns for File Tools
|
|
299
303
|
|
|
300
304
|
For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
|
|
@@ -993,6 +997,9 @@ Avoid arrays, multi-line scalars, and YAML anchors.
|
|
|
993
997
|
}
|
|
994
998
|
```
|
|
995
999
|
|
|
1000
|
+
The Bash tool stays visible to the agent here: the three `git` patterns are written after the `deny` catch-all, so they are reachable.
|
|
1001
|
+
Every other command is denied at the gate.
|
|
1002
|
+
|
|
996
1003
|
### Read-Only Bash Command Allowlist
|
|
997
1004
|
|
|
998
1005
|
The [Read-Only Mode](#read-only-mode) recipe above gates *tools*; this one gates the *bash* surface.
|
|
@@ -1142,6 +1149,7 @@ Additional behaviors:
|
|
|
1142
1149
|
|
|
1143
1150
|
- Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
|
|
1144
1151
|
- Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
|
|
1152
|
+
- A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
|
|
1145
1153
|
- The `Available tools:` system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed
|
|
1146
1154
|
- The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
|
|
1147
1155
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
|
|
@@ -69,9 +69,12 @@ interface PermissionsService {
|
|
|
69
69
|
agentName?: string,
|
|
70
70
|
): PermissionCheckResult;
|
|
71
71
|
|
|
72
|
-
/** Query
|
|
72
|
+
/** Query a surface's catch-all permission state — its blanket policy. */
|
|
73
73
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
74
74
|
|
|
75
|
+
/** Whether every value under a tool's surface resolves to deny; use this to pre-filter a tool list. */
|
|
76
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
77
|
+
|
|
75
78
|
/**
|
|
76
79
|
* Register a custom preview formatter for a specific tool name.
|
|
77
80
|
* Returns a disposer that unregisters the formatter.
|
|
@@ -130,14 +133,27 @@ Decomposition needs the tree-sitter parser, which is warmed at `before_agent_sta
|
|
|
130
133
|
#### `getToolPermission`
|
|
131
134
|
|
|
132
135
|
Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
|
|
133
|
-
|
|
136
|
+
It reports the surface's own catch-all, so it answers what a surface's blanket policy is.
|
|
134
137
|
|
|
135
138
|
```typescript
|
|
136
|
-
const
|
|
137
|
-
(t) => permissions.getToolPermission(t, agentName) === "deny",
|
|
138
|
-
);
|
|
139
|
+
const blanketPolicy = permissions.getToolPermission("bash", agentName);
|
|
139
140
|
```
|
|
140
141
|
|
|
142
|
+
This is not the question to ask when pre-filtering a tool list — use `isToolFullyDenied` for that.
|
|
143
|
+
A surface written as `bash: {"*": "deny", "git *": "ask"}` reports `"deny"` here while `git status` would still be asked about.
|
|
144
|
+
|
|
145
|
+
#### `isToolFullyDenied`
|
|
146
|
+
|
|
147
|
+
Returns `true` when every value under the tool's surface resolves to `deny`, and `false` when anything at all could get through.
|
|
148
|
+
Use this to pre-filter a tool list before creating a child session — it avoids calling `checkPermission` per tool and interpreting the full result, and unlike `getToolPermission` it does not withhold a tool that is only partially restricted.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
const usable = tools.filter((t) => !permissions.isToolFullyDenied(t, agentName));
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Rule ordering is honored (last-match-wins), so an exception written after a `deny` catch-all keeps the tool reachable while one written before it does not.
|
|
155
|
+
It considers config-layer rules only; a runtime session approval does not change the answer.
|
|
156
|
+
|
|
141
157
|
#### `registerToolInputFormatter`
|
|
142
158
|
|
|
143
159
|
Register a custom preview formatter for a specific tool name.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Migration guide: a session approval carries a surface per pattern
|
|
2
|
+
|
|
3
|
+
Starting with the release that closes #810, a session-scoped approval records each of its patterns on the surface that pattern's own access was proven on, instead of recording every pattern on one shared surface.
|
|
4
|
+
|
|
5
|
+
This is a **breaking change** on one surface, with a second, milder effect during a version-skew window.
|
|
6
|
+
|
|
7
|
+
| Surface | Break | Who is affected |
|
|
8
|
+
| ----------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
|
|
9
|
+
| `ForwardedSessionApproval` (a field of `PromptPermissionDetails`) | `surface` and `patterns` removed; `grants` added | Any extension whose registered `Authorizer` reads `details.sessionApproval` |
|
|
10
|
+
| `ForwardedPermissionRequest.sessionApproval` (on-disk wire) | the old shape is rejected, not normalized | Anyone running **out-of-process** subagents across mixed versions |
|
|
11
|
+
|
|
12
|
+
## What changed, and why it is not just a rename
|
|
13
|
+
|
|
14
|
+
A permission gate proves a direction for each path it sees: reading `/outside/a.ts` proves a read, redirecting into `/elsewhere/b.ts` proves a write, and an unclassifiable access proves neither.
|
|
15
|
+
The bash external-directory gate aggregates every uncovered path into **one** prompt, so it built one approval covering all of them.
|
|
16
|
+
|
|
17
|
+
Because that approval held a single surface, it could only be as narrow as the whole prompt agreed.
|
|
18
|
+
A command touching paths in two directions fell back to the direction-neutral family name, which grants both directions.
|
|
19
|
+
So approving
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cat /outside/a.ts > /elsewhere/b.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
for the session also granted writes under `/outside` and reads under `/elsewhere` — wider than the prompt named.
|
|
26
|
+
|
|
27
|
+
Each pattern now carries its own surface, so that approval grants a read under `/outside` and a write under `/elsewhere`, and nothing else.
|
|
28
|
+
|
|
29
|
+
Two paths in the **same** directory still grant both directions there.
|
|
30
|
+
The recorded pattern is the path's directory scope, so both paths produce the same pattern and the two grants land on the same directory in opposite directions — which is exactly what the prompt showed.
|
|
31
|
+
|
|
32
|
+
## If you register an `Authorizer`
|
|
33
|
+
|
|
34
|
+
A chain link receives `PromptPermissionDetails`, whose optional `sessionApproval` field changed shape:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// Before
|
|
38
|
+
const surface = details.sessionApproval?.surface;
|
|
39
|
+
const first = details.sessionApproval?.patterns[0];
|
|
40
|
+
|
|
41
|
+
// After
|
|
42
|
+
const first = details.sessionApproval?.grants[0];
|
|
43
|
+
const surface = first?.surface;
|
|
44
|
+
const pattern = first?.pattern;
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`grants` is a non-empty array of `{ surface, pattern }`, in the order the gate produced them.
|
|
48
|
+
Read each entry's own `surface` — do not assume the first one describes the rest, which is the assumption this change exists to remove.
|
|
49
|
+
|
|
50
|
+
Most links never touch this field; it exists so a serving node can offer a whole-session grant scope, and links that only rule on the request are unaffected.
|
|
51
|
+
|
|
52
|
+
## If you run out-of-process subagents
|
|
53
|
+
|
|
54
|
+
A subagent with no UI writes its ask to a request file that its parent session reads.
|
|
55
|
+
The suggestion travels in that file, and the reader accepts only the new shape — the old one is rejected rather than converted.
|
|
56
|
+
|
|
57
|
+
That matters only while a parent and a child are running **different** versions of this extension, which happens when an upgrade lands on disk while a parent session is already loaded.
|
|
58
|
+
An in-process child shares its parent's loaded extension and can never skew.
|
|
59
|
+
|
|
60
|
+
The effect is symmetric and bounded:
|
|
61
|
+
|
|
62
|
+
- The request is still accepted, and the ask still prompts.
|
|
63
|
+
Only the suggestion is dropped.
|
|
64
|
+
- With no suggestion, the prompt shows its base four options instead of asking whether the grant covers the whole serving session.
|
|
65
|
+
- Choosing "for this session" then records on the **requesting subagent** — the least-privilege default the two-step dialog already pre-selected.
|
|
66
|
+
|
|
67
|
+
So a skewed pair loses an affordance; it never produces a wider grant, and there is no upgrade ordering to observe.
|
|
68
|
+
Restart the serving session after upgrading to restore the scope step.
|
|
69
|
+
|
|
70
|
+
Carrying the old shape alongside the new was considered and declined.
|
|
71
|
+
The wire file turned out to be the smaller of the two breaks: the type is reachable from this package's published type declarations, so replacing its fields breaks a consumer at compile time regardless of what any file on disk contains — and a compatibility shape would have had to be maintained forever to avoid a break that had already happened.
|
|
72
|
+
|
|
73
|
+
## Related decisions
|
|
74
|
+
|
|
75
|
+
- [ADR 0006 — Grant-scope selection on forwarded approvals](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0006-forwarded-grant-scope-selection.md), amended by this change.
|
|
76
|
+
- [ADR 0013 — The permission policy model](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0013-permission-policy-model.md), §3–§4 for the read/write axis and §9 for a session approval as a policy source.
|
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One session-approval grant: a wildcard pattern approved on one surface.
|
|
3
|
+
*
|
|
4
|
+
* A gate proves a direction per accessed path (ADR 0013 §7), so an ask whose
|
|
5
|
+
* paths disagree records a surface per pattern rather than one for all of them
|
|
6
|
+
* (#810). This lives in its own module because both the {@link SessionApproval}
|
|
7
|
+
* value object and the forwarded wire type name it, and those two already
|
|
8
|
+
* import in one direction.
|
|
9
|
+
*/
|
|
10
|
+
export interface ApprovalGrant {
|
|
11
|
+
readonly surface: string;
|
|
12
|
+
readonly pattern: string;
|
|
13
|
+
}
|
|
@@ -350,16 +350,11 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
350
350
|
return decision;
|
|
351
351
|
}
|
|
352
352
|
if (request.sessionApproval) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
request.sessionApproval.surface,
|
|
356
|
-
request.sessionApproval.patterns,
|
|
357
|
-
),
|
|
358
|
-
);
|
|
353
|
+
const { grants } = request.sessionApproval;
|
|
354
|
+
this.recorder.recordSessionApproval(SessionApproval.forGrants(grants));
|
|
359
355
|
this.logger.review("forwarded_permission.session_recorded", {
|
|
360
356
|
...logDetails,
|
|
361
|
-
|
|
362
|
-
patterns: request.sessionApproval.patterns,
|
|
357
|
+
grants,
|
|
363
358
|
});
|
|
364
359
|
}
|
|
365
360
|
return {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
+
import type { ApprovalGrant } from "#src/approval-grant";
|
|
12
13
|
import { asDecisionSource } from "#src/authority/decision-source";
|
|
13
14
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
14
15
|
import {
|
|
@@ -49,12 +50,34 @@ function asNullableDisplayString(value: unknown): string | null | undefined {
|
|
|
49
50
|
return undefined;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Narrow an unknown value to an `ApprovalGrant`, or `undefined`. */
|
|
54
|
+
function asApprovalGrant(value: unknown): ApprovalGrant | undefined {
|
|
55
|
+
if (typeof value !== "object" || value === null) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const candidate = value as Partial<ApprovalGrant>;
|
|
59
|
+
if (
|
|
60
|
+
typeof candidate.surface !== "string" ||
|
|
61
|
+
candidate.surface.length === 0 ||
|
|
62
|
+
typeof candidate.pattern !== "string"
|
|
63
|
+
) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return { surface: candidate.surface, pattern: candidate.pattern };
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/**
|
|
53
70
|
* Narrow an unknown value to a `ForwardedSessionApproval`, or `undefined`.
|
|
54
71
|
*
|
|
55
72
|
* Tolerant read: the child's session-approval suggestion is optional (absent
|
|
56
|
-
* on an older child) and only accepted when well-formed — a non-empty
|
|
57
|
-
*
|
|
73
|
+
* on an older child) and only accepted when well-formed — a non-empty `grants`
|
|
74
|
+
* array whose every entry names a non-empty surface and a pattern. The
|
|
75
|
+
* pre-#810 shape (`surface` plus `patterns`) is rejected rather than
|
|
76
|
+
* normalized, so a version-skewed request drops the suggestion and the serving
|
|
77
|
+
* dialog offers no whole-session scope.
|
|
78
|
+
*
|
|
79
|
+
* An empty `grants` array is rejected too: it would record nothing while still
|
|
80
|
+
* writing a `forwarded_permission.session_recorded` entry claiming it had.
|
|
58
81
|
*/
|
|
59
82
|
function asForwardedSessionApproval(
|
|
60
83
|
value: unknown,
|
|
@@ -63,15 +86,16 @@ function asForwardedSessionApproval(
|
|
|
63
86
|
return undefined;
|
|
64
87
|
}
|
|
65
88
|
const candidate = value as Partial<ForwardedSessionApproval>;
|
|
66
|
-
if (
|
|
67
|
-
typeof candidate.surface !== "string" ||
|
|
68
|
-
candidate.surface.length === 0 ||
|
|
69
|
-
!Array.isArray(candidate.patterns) ||
|
|
70
|
-
!candidate.patterns.every((pattern) => typeof pattern === "string")
|
|
71
|
-
) {
|
|
89
|
+
if (!Array.isArray(candidate.grants) || candidate.grants.length === 0) {
|
|
72
90
|
return undefined;
|
|
73
91
|
}
|
|
74
|
-
|
|
92
|
+
const grants: ApprovalGrant[] = [];
|
|
93
|
+
for (const entry of candidate.grants) {
|
|
94
|
+
const grant = asApprovalGrant(entry);
|
|
95
|
+
if (!grant) return undefined;
|
|
96
|
+
grants.push(grant);
|
|
97
|
+
}
|
|
98
|
+
return { grants };
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
/**
|
|
@@ -72,13 +72,13 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
72
72
|
function buildRequestOptions(
|
|
73
73
|
details: PromptPermissionDetails,
|
|
74
74
|
): RequestPermissionOptions | undefined {
|
|
75
|
-
const
|
|
76
|
-
if (details.forwarding &&
|
|
75
|
+
const grant = details.sessionApproval?.grants[0];
|
|
76
|
+
if (details.forwarding && grant) {
|
|
77
77
|
return {
|
|
78
78
|
sessionScope: buildForwardedScopeLabels(
|
|
79
79
|
details.forwarding.requesterAgentName,
|
|
80
|
-
|
|
81
|
-
pattern,
|
|
80
|
+
grant.surface,
|
|
81
|
+
grant.pattern,
|
|
82
82
|
),
|
|
83
83
|
};
|
|
84
84
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { ApprovalGrant } from "#src/approval-grant";
|
|
2
3
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
3
4
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
4
5
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
@@ -83,16 +84,21 @@ export interface ForwardedPromptDisplay {
|
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* The child's session-approval suggestion, relayed to the serving node so a
|
|
86
|
-
* human who grants "the whole session" records the same
|
|
87
|
-
*
|
|
87
|
+
* human who grants "the whole session" records the same grants the child would
|
|
88
|
+
* have recorded locally.
|
|
88
89
|
*
|
|
89
90
|
* A plain data shape (not the `SessionApproval` value object) so it serializes
|
|
90
91
|
* onto the forwarded request; the serving node rebuilds a `SessionApproval`
|
|
91
|
-
* from it via `SessionApproval.
|
|
92
|
+
* from it via `SessionApproval.forGrants`.
|
|
93
|
+
*
|
|
94
|
+
* Each grant carries its own surface (#810). The pre-#810 shape — one
|
|
95
|
+
* `surface` plus a `patterns` list — is rejected by the reader rather than
|
|
96
|
+
* normalized, so a version-skewed pair drops the suggestion and the serving
|
|
97
|
+
* dialog offers no whole-session scope; the requesting child still records its
|
|
98
|
+
* own grant, so the failure is narrow in both directions.
|
|
92
99
|
*/
|
|
93
100
|
export interface ForwardedSessionApproval {
|
|
94
|
-
|
|
95
|
-
patterns: readonly string[];
|
|
101
|
+
grants: readonly ApprovalGrant[];
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
/**
|
|
@@ -8,7 +8,6 @@ import type { PermissionSession } from "#src/permission-session";
|
|
|
8
8
|
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
|
9
9
|
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
|
|
10
10
|
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
|
|
11
|
-
import type { PermissionState } from "#src/types";
|
|
12
11
|
|
|
13
12
|
/** Minimal subset of BeforeAgentStartEvent used by this handler. */
|
|
14
13
|
interface BeforeAgentStartPayload {
|
|
@@ -17,16 +16,17 @@ interface BeforeAgentStartPayload {
|
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Pure helper: returns true when the tool should be exposed to the agent.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
*
|
|
20
|
+
* A tool is withheld only when *every* value under its surface resolves to
|
|
21
|
+
* `deny`, so a blanket `bash: deny` hides the tool entirely while a partially
|
|
22
|
+
* permissive `bash: {"*": "deny", "git *": "ask"}` keeps it reachable (#815).
|
|
22
23
|
*/
|
|
23
24
|
export function shouldExposeTool(
|
|
24
25
|
toolName: string,
|
|
25
26
|
agentName: string | null,
|
|
26
|
-
|
|
27
|
+
isToolFullyDenied: (toolName: string, agentName?: string) => boolean,
|
|
27
28
|
): boolean {
|
|
28
|
-
|
|
29
|
-
return toolPermission !== "deny";
|
|
29
|
+
return !isToolFullyDenied(toolName, agentName ?? undefined);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -41,7 +41,7 @@ export function shouldExposeTool(
|
|
|
41
41
|
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
42
42
|
* session state
|
|
43
43
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
44
|
-
* - `resolver` — owns permission-query surface: `
|
|
44
|
+
* - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
|
|
45
45
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
46
46
|
*/
|
|
47
47
|
export class AgentPrepHandler {
|
|
@@ -70,7 +70,7 @@ export class AgentPrepHandler {
|
|
|
70
70
|
}
|
|
71
71
|
if (
|
|
72
72
|
shouldExposeTool(toolName, agentName, (t, a) =>
|
|
73
|
-
this.resolver.
|
|
73
|
+
this.resolver.isToolFullyDenied(t, a),
|
|
74
74
|
)
|
|
75
75
|
) {
|
|
76
76
|
allowedTools.push(toolName);
|
|
@@ -18,11 +18,11 @@ import type { ToolCallContext } from "./types";
|
|
|
18
18
|
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
|
|
19
19
|
*
|
|
20
20
|
* Each path is resolved on the narrowest `external_directory`-family surface
|
|
21
|
-
* its own attributed effect names
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* its own attributed effect names, and the session approval records one grant
|
|
22
|
+
* per uncovered path at that same surface (#810) — so an ask mixing a proven
|
|
23
|
+
* read with a proven write grants each path only its own direction, never both
|
|
24
|
+
* on both. Two paths sharing a directory derive the same glob and so grant
|
|
25
|
+
* both directions there, which is what the prompt showed.
|
|
26
26
|
*
|
|
27
27
|
* The shell command (native `bash` or an aliased shell tool) is read from the
|
|
28
28
|
* injected `BashProgram`, which owns the source text it was parsed from, so
|
|
@@ -104,17 +104,15 @@ export function describeBashExternalDirectoryGate(
|
|
|
104
104
|
surface,
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
const patterns = uncoveredEntries.map(({ path }) =>
|
|
108
|
-
normalizer.approvalPatternFor(path),
|
|
109
|
-
);
|
|
110
|
-
|
|
111
107
|
return {
|
|
112
108
|
surface,
|
|
113
109
|
input: {},
|
|
114
110
|
payload,
|
|
115
|
-
sessionApproval: SessionApproval.
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
sessionApproval: SessionApproval.forGrants(
|
|
112
|
+
uncoveredEntries.map((entry) => ({
|
|
113
|
+
surface: entry.surface,
|
|
114
|
+
pattern: normalizer.approvalPatternFor(entry.path),
|
|
115
|
+
})),
|
|
118
116
|
),
|
|
119
117
|
promptDetails: {
|
|
120
118
|
source: "tool_call",
|
|
@@ -143,20 +141,3 @@ export function describeBashExternalDirectoryGate(
|
|
|
143
141
|
preCheck,
|
|
144
142
|
};
|
|
145
143
|
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The surface one session approval can carry for every uncovered path at once.
|
|
149
|
-
*
|
|
150
|
-
* A {@link SessionApproval} holds one surface for all its patterns, so it can
|
|
151
|
-
* narrow only when the whole ask agrees on a direction. The bare family is the
|
|
152
|
-
* fallback because it sugar-expands onto both members — exactly the width a
|
|
153
|
-
* mixed-direction command is granted today, never wider.
|
|
154
|
-
*/
|
|
155
|
-
function approvalSurfaceFor(
|
|
156
|
-
uncoveredEntries: readonly { readonly surface: string }[],
|
|
157
|
-
): string {
|
|
158
|
-
const surfaces = new Set(uncoveredEntries.map(({ surface }) => surface));
|
|
159
|
-
return surfaces.size === 1
|
|
160
|
-
? [...surfaces][0]
|
|
161
|
-
: ("external_directory" as const);
|
|
162
|
-
}
|
|
@@ -209,7 +209,7 @@ export class GateRunner {
|
|
|
209
209
|
};
|
|
210
210
|
const gateResult = await applyPermissionGate({
|
|
211
211
|
state: check.state,
|
|
212
|
-
|
|
212
|
+
canGrantForSession: descriptor.sessionApproval?.isRecordable ?? false,
|
|
213
213
|
promptForApproval: async () => {
|
|
214
214
|
const decision = await this.prompter.escalate({
|
|
215
215
|
requestId,
|
|
@@ -230,7 +230,7 @@ export class GateRunner {
|
|
|
230
230
|
|
|
231
231
|
// 4. Determine whether session approval was granted
|
|
232
232
|
const hasSessionApproval =
|
|
233
|
-
gateResult.action === "allow" && gateResult.
|
|
233
|
+
gateResult.action === "allow" && gateResult.forSession === true;
|
|
234
234
|
|
|
235
235
|
// 5. Emit decision event
|
|
236
236
|
this.emitDecision(
|
package/src/permission-gate.ts
CHANGED
|
@@ -13,7 +13,8 @@ export type PermissionGateResult =
|
|
|
13
13
|
| {
|
|
14
14
|
action: "allow";
|
|
15
15
|
decidedBy: DecisionSource;
|
|
16
|
-
|
|
16
|
+
/** Set when the human granted the ask for the whole session. */
|
|
17
|
+
forSession?: true;
|
|
17
18
|
}
|
|
18
19
|
| { action: "block"; decidedBy: DecisionSource; reason: string };
|
|
19
20
|
|
|
@@ -30,11 +31,16 @@ export interface PermissionGateParams {
|
|
|
30
31
|
promptForApproval: () => Promise<PermissionPromptDecision>;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
-
* "for this session".
|
|
35
|
-
*
|
|
34
|
+
* Whether this ask has a session-approval suggestion to record when the user
|
|
35
|
+
* selects "for this session".
|
|
36
|
+
*
|
|
37
|
+
* A boolean rather than the suggestion itself: the gate decides only whether
|
|
38
|
+
* a whole-session grant happened, and the caller records the suggestion it
|
|
39
|
+
* already holds. Handing the gate the value would ask it to name a single
|
|
40
|
+
* representative `(surface, pattern)`, which a multi-pattern approval has no
|
|
41
|
+
* way to choose (#810).
|
|
36
42
|
*/
|
|
37
|
-
|
|
43
|
+
canGrantForSession: boolean;
|
|
38
44
|
|
|
39
45
|
/** Write a review-log entry. Called for deny and ask-but-unavailable paths. */
|
|
40
46
|
writeLog: (event: string, extra: Record<string, unknown>) => void;
|
|
@@ -102,12 +108,11 @@ export async function applyPermissionGate(
|
|
|
102
108
|
reason: messages.refusedReason(decision),
|
|
103
109
|
};
|
|
104
110
|
}
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
};
|
|
111
|
+
if (
|
|
112
|
+
decision.state === "approved_for_session" &&
|
|
113
|
+
params.canGrantForSession
|
|
114
|
+
) {
|
|
115
|
+
return { action: "allow", decidedBy, forSession: true };
|
|
111
116
|
}
|
|
112
117
|
return { action: "allow", decidedBy };
|
|
113
118
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
evaluateAnyValue,
|
|
23
23
|
evaluateFirst,
|
|
24
24
|
floorAllowsToAsk,
|
|
25
|
+
isSurfaceFullyDenied,
|
|
25
26
|
rewriteAsksToYolo,
|
|
26
27
|
} from "./rule";
|
|
27
28
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
@@ -83,6 +84,7 @@ export interface ScopedPermissionManager {
|
|
|
83
84
|
sessionRules?: Ruleset,
|
|
84
85
|
): PermissionCheckResult;
|
|
85
86
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
87
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
86
88
|
getConfigIssues(agentName?: string): string[];
|
|
87
89
|
}
|
|
88
90
|
|
|
@@ -268,6 +270,24 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
268
270
|
return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
|
|
269
271
|
}
|
|
270
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Whether every value under a tool's surface resolves to `deny`.
|
|
275
|
+
*
|
|
276
|
+
* This is the question tool exposure asks, and it is not
|
|
277
|
+
* {@link PermissionManager.getToolPermission} — that reports the surface's
|
|
278
|
+
* catch-all, so `bash: {"*": "deny", "git *": "ask"}` reads as `deny` even
|
|
279
|
+
* though `git status` would be asked about (#815).
|
|
280
|
+
*
|
|
281
|
+
* Reads the same composed rules the catch-all query does, so it inherits the
|
|
282
|
+
* fail-closed floor and not the yolo rewrite. Neither matters: one touches
|
|
283
|
+
* only `allow` and the other only `ask`, so neither can create or remove the
|
|
284
|
+
* `deny` this answer turns on.
|
|
285
|
+
*/
|
|
286
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean {
|
|
287
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
288
|
+
return isSurfaceFullyDenied(toolName.trim(), composedRules, this.flavor);
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
/**
|
|
272
292
|
* Unified resolution entry point — dispatches on intent kind.
|
|
273
293
|
*
|
|
@@ -126,10 +126,19 @@ export class PermissionResolver
|
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Reached only through `LocalPermissionsService`'s structural resolver view,
|
|
130
|
+
// which fallow cannot trace; `tsc` enforces it at that constructor. The
|
|
131
|
+
// handler's exposure check moved to `isToolFullyDenied` in #815, leaving this
|
|
132
|
+
// the published cross-extension catch-all query and nothing else.
|
|
133
|
+
// fallow-ignore-next-line unused-class-member
|
|
129
134
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
130
135
|
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean {
|
|
139
|
+
return this.permissionManager.isToolFullyDenied(toolName, agentName);
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
getConfigIssues(agentName?: string): string[] {
|
|
134
143
|
return this.permissionManager.getConfigIssues(agentName);
|
|
135
144
|
}
|
|
@@ -25,6 +25,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
|
|
|
25
25
|
interface ResolverForService {
|
|
26
26
|
resolve(intent: AccessIntent): PermissionCheckResult;
|
|
27
27
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
28
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** Narrow session view: hands out the cwd-bound path normalizer. */
|
|
@@ -81,6 +82,13 @@ export class LocalPermissionsService implements PermissionsService {
|
|
|
81
82
|
return this.resolver.getToolPermission(toolName, agentName);
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
isToolFullyDenied(
|
|
86
|
+
toolName: string,
|
|
87
|
+
agentName?: string,
|
|
88
|
+
): ReturnType<PermissionsService["isToolFullyDenied"]> {
|
|
89
|
+
return this.resolver.isToolFullyDenied(toolName, agentName);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
registerToolInputFormatter(
|
|
85
93
|
toolName: string,
|
|
86
94
|
formatter: ToolInputFormatter,
|
package/src/rule.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
2
|
|
|
3
3
|
import { PATH_SURFACES } from "./access-intent/path-surfaces";
|
|
4
|
+
import { expandHomePath } from "./expand-home";
|
|
4
5
|
import type { PermissionState } from "./types";
|
|
5
6
|
import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
|
|
6
7
|
|
|
@@ -139,6 +140,54 @@ function ruleMatches(
|
|
|
139
140
|
);
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Whether every value on `surface` resolves to `deny`.
|
|
145
|
+
*
|
|
146
|
+
* This is a different question from "what does the surface's catch-all say?",
|
|
147
|
+
* and it carries a different burden of proof: the catch-all answers *what this
|
|
148
|
+
* surface is*, while this answers *whether anything at all could get through* —
|
|
149
|
+
* the question tool exposure has to ask before withholding a tool from the
|
|
150
|
+
* agent entirely.
|
|
151
|
+
*
|
|
152
|
+
* Reachability is decided by probing each pattern configured on the surface as
|
|
153
|
+
* a representative value through {@link evaluate}, so last-match-wins shadowing
|
|
154
|
+
* is honored: an exception written *after* a `deny` catch-all is reachable,
|
|
155
|
+
* while one written *before* it is not.
|
|
156
|
+
*
|
|
157
|
+
* The probe is an approximation of "does any string resolve non-deny", not a
|
|
158
|
+
* decision procedure for it. Being wrong in either direction only changes
|
|
159
|
+
* whether the agent sees the tool — the invocation gate re-evaluates the real
|
|
160
|
+
* value against the same ruleset either way.
|
|
161
|
+
*/
|
|
162
|
+
export function isSurfaceFullyDenied(
|
|
163
|
+
surface: string,
|
|
164
|
+
rules: Ruleset,
|
|
165
|
+
flavor: PathFlavor,
|
|
166
|
+
): boolean {
|
|
167
|
+
for (const value of probeValuesForSurface(surface, rules)) {
|
|
168
|
+
if (evaluate(surface, value, rules, flavor).action !== "deny") return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The representative values {@link isSurfaceFullyDenied} probes: the catch-all,
|
|
175
|
+
* plus every pattern configured on a rule whose surface reaches `surface`.
|
|
176
|
+
*
|
|
177
|
+
* Each pattern is home-expanded because {@link compileWildcardPattern} expands
|
|
178
|
+
* the *pattern* side only; an unexpanded `~/notes/*` probe would fail to match
|
|
179
|
+
* its own rule and report the surface denied.
|
|
180
|
+
*/
|
|
181
|
+
function probeValuesForSurface(surface: string, rules: Ruleset): Set<string> {
|
|
182
|
+
const values = new Set<string>(["*"]);
|
|
183
|
+
for (const rule of rules) {
|
|
184
|
+
if (wildcardMatch(rule.surface, surface)) {
|
|
185
|
+
values.add(expandHomePath(rule.pattern));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return values;
|
|
189
|
+
}
|
|
190
|
+
|
|
142
191
|
/**
|
|
143
192
|
* Evaluate a surface against multiple values, returning the most restrictive
|
|
144
193
|
* non-allow result (deny > ask > allow).
|
package/src/service.ts
CHANGED
|
@@ -99,13 +99,17 @@ export interface PermissionQuery {
|
|
|
99
99
|
): PermissionCheckResult;
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* Query
|
|
103
|
-
* creating a child session.
|
|
102
|
+
* Query a surface's catch-all permission state — its blanket policy.
|
|
104
103
|
*
|
|
105
104
|
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
106
105
|
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
107
106
|
* use `checkPermission` for runtime invocation gates.
|
|
108
107
|
*
|
|
108
|
+
* This is **not** the question to ask when pre-filtering a tool list: a
|
|
109
|
+
* partially permissive surface such as `bash: {"*": "deny", "git *": "ask"}`
|
|
110
|
+
* answers `"deny"` here while `git status` would still be asked about. Use
|
|
111
|
+
* {@link PermissionsService.isToolFullyDenied} for that.
|
|
112
|
+
*
|
|
109
113
|
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
110
114
|
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
111
115
|
*/
|
|
@@ -126,6 +130,24 @@ export interface PermissionQuery {
|
|
|
126
130
|
* rules internally.
|
|
127
131
|
*/
|
|
128
132
|
export interface PermissionsService extends PermissionQuery {
|
|
133
|
+
/**
|
|
134
|
+
* Whether every value under a tool's surface resolves to `deny`.
|
|
135
|
+
*
|
|
136
|
+
* This is the question to ask before withholding a tool from a child
|
|
137
|
+
* session's tool list, and it is not `getToolPermission`: that reports the
|
|
138
|
+
* surface's own catch-all, so a partially permissive surface such as
|
|
139
|
+
* `bash: {"*": "deny", "git *": "ask"}` reads as `"deny"` while `git status`
|
|
140
|
+
* would still be asked about.
|
|
141
|
+
*
|
|
142
|
+
* Rule ordering is honored (last-match-wins), so an exception written *after*
|
|
143
|
+
* a `deny` catch-all keeps the tool reachable and one written *before* it
|
|
144
|
+
* does not.
|
|
145
|
+
*
|
|
146
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
147
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
148
|
+
*/
|
|
149
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
150
|
+
|
|
129
151
|
/**
|
|
130
152
|
* Register a custom preview formatter for a specific tool name.
|
|
131
153
|
*
|
package/src/session-approval.ts
CHANGED
|
@@ -1,54 +1,44 @@
|
|
|
1
|
+
import type { ApprovalGrant } from "#src/approval-grant";
|
|
1
2
|
import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Value object for a session-scoped approval: one
|
|
5
|
+
* Value object for a session-scoped approval: one or more
|
|
6
|
+
* {@link ApprovalGrant}s, each pairing a pattern with the surface it was
|
|
7
|
+
* proven on.
|
|
5
8
|
*
|
|
6
9
|
* Owned by gate descriptors and passed to the session store — the runner never
|
|
7
|
-
* needs to know
|
|
10
|
+
* needs to know how many grants an approval carries, and the store records
|
|
11
|
+
* each on the surface the grant itself names rather than one shared by all
|
|
12
|
+
* (#810).
|
|
8
13
|
*/
|
|
9
14
|
export class SessionApproval {
|
|
10
|
-
private constructor(
|
|
11
|
-
readonly surface: string,
|
|
12
|
-
readonly patterns: readonly string[],
|
|
13
|
-
) {}
|
|
15
|
+
private constructor(readonly grants: readonly ApprovalGrant[]) {}
|
|
14
16
|
|
|
15
17
|
/** Create an approval for a single pattern (the common case). */
|
|
16
18
|
static single(surface: string, pattern: string): SessionApproval {
|
|
17
|
-
return new SessionApproval(surface,
|
|
19
|
+
return new SessionApproval([{ surface, pattern }]);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
|
-
* Create an approval
|
|
22
|
-
*
|
|
23
|
+
* Create an approval from grants that may name different surfaces (e.g. a
|
|
24
|
+
* bash external-directory ask whose uncovered paths proved different
|
|
25
|
+
* directions). Returns a defensive copy.
|
|
23
26
|
*/
|
|
24
|
-
static
|
|
25
|
-
|
|
26
|
-
patterns: readonly string[],
|
|
27
|
-
): SessionApproval {
|
|
28
|
-
return new SessionApproval(surface, [...patterns]);
|
|
27
|
+
static forGrants(grants: readonly ApprovalGrant[]): SessionApproval {
|
|
28
|
+
return new SessionApproval([...grants]);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
get
|
|
33
|
-
return this.
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Single-pattern shape `applyPermissionGate` echoes back to the caller.
|
|
38
|
-
* Returns `undefined` when patterns is empty (degenerate case).
|
|
39
|
-
*/
|
|
40
|
-
toGateApproval(): { surface: string; pattern: string } | undefined {
|
|
41
|
-
const pattern = this.representativePattern;
|
|
42
|
-
if (pattern === undefined) return undefined;
|
|
43
|
-
return { surface: this.surface, pattern };
|
|
31
|
+
/** Whether this approval carries anything for the session store to record. */
|
|
32
|
+
get isRecordable(): boolean {
|
|
33
|
+
return this.grants.length > 0;
|
|
44
34
|
}
|
|
45
35
|
|
|
46
36
|
/**
|
|
47
37
|
* Plain data shape for relaying this approval on a forwarded request, so the
|
|
48
|
-
* serving node can record the same
|
|
49
|
-
* Returns a defensive copy
|
|
38
|
+
* serving node can record the same grants as a whole-session grant.
|
|
39
|
+
* Returns a defensive copy.
|
|
50
40
|
*/
|
|
51
41
|
toForwardedData(): ForwardedSessionApproval {
|
|
52
|
-
return {
|
|
42
|
+
return { grants: [...this.grants] };
|
|
53
43
|
}
|
|
54
44
|
}
|
package/src/session-rules.ts
CHANGED
|
@@ -41,14 +41,16 @@ export class SessionRules implements SessionApprovalRecorder {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Record
|
|
44
|
+
* Record every grant from a `SessionApproval` value object.
|
|
45
45
|
*
|
|
46
|
-
* The loop lives here so callers never need to know
|
|
47
|
-
* carries
|
|
46
|
+
* The loop lives here so callers never need to know how many grants an
|
|
47
|
+
* approval carries — they just tell the store to record it. Each grant is
|
|
48
|
+
* recorded on the surface it names, so an ask whose paths proved different
|
|
49
|
+
* directions grants each path only its own (#810).
|
|
48
50
|
*/
|
|
49
51
|
recordSessionApproval(approval: SessionApproval): void {
|
|
50
|
-
for (const pattern of approval.
|
|
51
|
-
this.approve(
|
|
52
|
+
for (const { surface, pattern } of approval.grants) {
|
|
53
|
+
this.approve(surface, pattern);
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|