@gotgenes/pi-permission-system 24.0.0 → 25.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 +42 -0
- package/config/config.example.json +2 -0
- package/docs/configuration.md +33 -17
- package/docs/opencode-compatibility.md +1 -1
- package/docs/subagent-integration.md +25 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +8 -0
- package/src/access-intent/bash/node-text.ts +5 -1
- package/src/access-intent/bash/shell-variable-expansion.ts +77 -0
- package/src/authority/approval-escalator.ts +112 -23
- package/src/authority/authorizer.ts +7 -0
- package/src/authority/forwarding-manager.ts +60 -6
- package/src/authority/permission-dialog.ts +8 -5
- package/src/authority/permission-forwarding.ts +37 -5
- package/src/authority/serving-registry.ts +108 -0
- package/src/config-loader.ts +1 -0
- package/src/config-schema.ts +7 -0
- package/src/denial-messages.ts +21 -4
- package/src/expand-home.ts +30 -16
- package/src/extension-config.ts +5 -0
- package/src/handlers/gates/runner.ts +5 -1
- package/src/index.ts +15 -1
- package/src/permission-gate.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [25.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.0.0...pi-permission-system-v25.1.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add a process-global serving-session registry ([2939982](https://github.com/gotgenes/pi-packages/commit/2939982f80669a65df356f17e8110174ff96e68a)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
14
|
+
* **pi-permission-system:** announce the session serving forwarded requests ([74c0259](https://github.com/gotgenes/pi-packages/commit/74c0259141e616b1cb7760427e507fac518ba2e4)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
15
|
+
* **pi-permission-system:** make the forwarding timeout configurable ([fc6190d](https://github.com/gotgenes/pi-packages/commit/fc6190de23eb6347a135bf85e45f3060ac9b6868)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** carry the denial reason into the unavailable block message ([1a0b289](https://github.com/gotgenes/pi-packages/commit/1a0b2894854576d2a171e98a10939c2c4c56794b)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
21
|
+
* **pi-permission-system:** fail fast when no session serves a forwarded request ([5241df0](https://github.com/gotgenes/pi-packages/commit/5241df0d6986fa66e1f21ccab6de956ac9d630ce)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
22
|
+
* **pi-permission-system:** report forwarding failures as unavailable, not user-denied ([6c37d27](https://github.com/gotgenes/pi-packages/commit/6c37d2778c0be51423b9b80569205ce68a0ac977)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** document forwarding liveness and the timeout field ([e493991](https://github.com/gotgenes/pi-packages/commit/e493991d67eb4e305a8a50346b1affed39ecbd96)), closes [#719](https://github.com/gotgenes/pi-packages/issues/719)
|
|
28
|
+
|
|
29
|
+
## [25.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v24.0.0...pi-permission-system-v25.0.0) (2026-08-11)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### ⚠ BREAKING CHANGES
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** a bash command referencing `$HOME` or `${HOME}` now reaches the `external_directory` gate whether or not the target exists, so a policy with `external_directory: {"*": "ask"}` prompts for commands that previously ran silently. Allow the directory explicitly to restore the old behavior — e.g. `"external_directory": {"~/.cargo/registry/*": "allow"}`. The token shown in prompts, review-log entries, and derived session-approval patterns is now the expanded path rather than the `$HOME/...` spelling; this makes the prompt agree with the approval pattern, which was already derived from the expanded form.
|
|
35
|
+
|
|
36
|
+
### Features
|
|
37
|
+
|
|
38
|
+
* **pi-permission-system:** expand ${HOME} alongside $HOME in path patterns ([3ebbd41](https://github.com/gotgenes/pi-packages/commit/3ebbd418e5b85a6dfbc1865c69f62c1f9a3cb144)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### Bug Fixes
|
|
42
|
+
|
|
43
|
+
* **pi-permission-system:** resolve $HOME and $PWD expansions in bash path tokens ([8cbc7ee](https://github.com/gotgenes/pi-packages/commit/8cbc7ee011a3e06dcf047bfe8a467db3aa487721)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
|
|
48
|
+
* **pi-permission-system:** record resolved shell expansions in ADR 0009 and user docs ([caaf5ec](https://github.com/gotgenes/pi-packages/commit/caaf5ec02ef2f7df1e2a922dba0a9afdc121b191)), closes [#694](https://github.com/gotgenes/pi-packages/issues/694)
|
|
49
|
+
|
|
8
50
|
## [24.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.3...pi-permission-system-v24.0.0) (2026-07-26)
|
|
9
51
|
|
|
10
52
|
|
package/docs/configuration.md
CHANGED
|
@@ -36,7 +36,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
|
|
|
36
36
|
4. Project agent frontmatter
|
|
37
37
|
|
|
38
38
|
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
|
39
|
-
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
|
|
39
|
+
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`) use simple replacement.
|
|
40
40
|
|
|
41
41
|
**Invalid higher-precedence scope fails closed.**
|
|
42
42
|
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
|
@@ -57,6 +57,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
57
57
|
"permissionReviewLog": true,
|
|
58
58
|
"yoloMode": false,
|
|
59
59
|
"doublePressToConfirm": true,
|
|
60
|
+
"forwardingTimeoutMs": 600000,
|
|
60
61
|
"toolInputPreviewMaxLength": 400,
|
|
61
62
|
"toolTextSummaryMaxLength": 120,
|
|
62
63
|
"piInfrastructureReadPaths": [],
|
|
@@ -98,16 +99,17 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
98
99
|
|
|
99
100
|
## Runtime Knobs
|
|
100
101
|
|
|
101
|
-
| Key | Default
|
|
102
|
-
| --------------------------- |
|
|
103
|
-
| `debugLog` | `false`
|
|
104
|
-
| `permissionReviewLog` | `true`
|
|
105
|
-
| `yoloMode` | `false`
|
|
106
|
-
| `doublePressToConfirm` | `true`
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
102
|
+
| Key | Default | Description |
|
|
103
|
+
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
104
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
105
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings verbatim — see [Log file sensitivity](#log-file-sensitivity) |
|
|
106
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
107
|
+
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
108
|
+
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose in-process parent is not draining its inbox gives up in ~2 s regardless. |
|
|
109
|
+
| `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
|
|
110
|
+
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
|
|
111
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
|
|
112
|
+
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
|
111
113
|
|
|
112
114
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
113
115
|
No debug output is printed to the terminal.
|
|
@@ -136,7 +138,7 @@ Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt
|
|
|
136
138
|
### `piInfrastructureReadPaths` patterns
|
|
137
139
|
|
|
138
140
|
Each entry is either a plain directory prefix or a wildcard pattern.
|
|
139
|
-
Plain entries match any path that starts with the given directory (after `~`/`$HOME` expansion).
|
|
141
|
+
Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
|
|
140
142
|
Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
|
|
141
143
|
`*` and `**` are equivalent — both cross directory boundaries.
|
|
142
144
|
|
|
@@ -587,9 +589,22 @@ The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs a
|
|
|
587
589
|
For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
|
|
588
590
|
Use `external_directory` when the allowance must apply to every tool.
|
|
589
591
|
|
|
590
|
-
Bash commands are also covered: the extension
|
|
591
|
-
|
|
592
|
-
|
|
592
|
+
Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
|
|
593
|
+
Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
|
|
594
|
+
|
|
595
|
+
What the bash projection resolves:
|
|
596
|
+
|
|
597
|
+
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
|
|
598
|
+
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
|
|
599
|
+
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
|
|
600
|
+
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
|
|
601
|
+
|
|
602
|
+
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
|
|
603
|
+
A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
|
|
604
|
+
Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
|
|
605
|
+
The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
|
|
606
|
+
|
|
607
|
+
(The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
|
|
593
608
|
|
|
594
609
|
#### Symlinked paths
|
|
595
610
|
|
|
@@ -644,8 +659,9 @@ To allow-list such a path, write the rule using the path as typed — for exampl
|
|
|
644
659
|
|
|
645
660
|
### Home Directory Expansion in Patterns
|
|
646
661
|
|
|
647
|
-
Pattern keys in any permission surface can start with
|
|
662
|
+
Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
|
|
648
663
|
They are expanded to the OS home directory at match time, so configs are portable across machines and users.
|
|
664
|
+
A prefix is recognized only when it stands alone or precedes a separator, so a longer name (`~username`, `$HOMEDIR`) and a braced expansion carrying an operator (`${HOME:-/tmp}`) are left alone.
|
|
649
665
|
|
|
650
666
|
```jsonc
|
|
651
667
|
{
|
|
@@ -661,7 +677,7 @@ They are expanded to the OS home directory at match time, so configs are portabl
|
|
|
661
677
|
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
|
662
678
|
|
|
663
679
|
Path **values** supplied by tool calls and bash commands are expanded the same way.
|
|
664
|
-
This means `~/...`, `$HOME/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
|
|
680
|
+
This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
|
|
665
681
|
|
|
666
682
|
---
|
|
667
683
|
|
|
@@ -20,7 +20,7 @@ The following concepts are shared between OpenCode and this extension:
|
|
|
20
20
|
| Last-match-wins | When multiple patterns match, the last one in config order wins |
|
|
21
21
|
| `*` wildcard | Matches zero or more of any character (including path separators) |
|
|
22
22
|
| `?` wildcard | Matches exactly one character |
|
|
23
|
-
| Home directory expansion |
|
|
23
|
+
| Home directory expansion | `~/`, `$HOME/`, and `${HOME}/` expand to the OS home directory in patterns |
|
|
24
24
|
| `external_directory` surface | Gates access to paths outside the working directory |
|
|
25
25
|
| `bash` surface | Command patterns matched against shell commands |
|
|
26
26
|
| `skill` surface | Skill name patterns matched against skill invocations |
|
|
@@ -32,6 +32,31 @@ This keeps `ask` policies usable even when the original permission check happens
|
|
|
32
32
|
|
|
33
33
|
For in-process child sessions, detection and forwarding use the event-driven registration described above.
|
|
34
34
|
|
|
35
|
+
### When nobody answers
|
|
36
|
+
|
|
37
|
+
A forwarded request is only useful if some session is draining the inbox it was written into.
|
|
38
|
+
The polling session publishes the session id it polls, and an in-process child checks that its target is published before committing to a long wait.
|
|
39
|
+
|
|
40
|
+
If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
[pi-permission-system] Running bash command 'pwd' requires approval, but no
|
|
44
|
+
interactive UI is available. Reason: Session 'abc123' is not serving forwarded
|
|
45
|
+
permission requests.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
49
|
+
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
50
|
+
|
|
51
|
+
Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
|
|
52
|
+
None of them is reported as a user denial, because no user was ever asked.
|
|
53
|
+
|
|
54
|
+
The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
|
|
55
|
+
When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
|
|
56
|
+
|
|
57
|
+
This liveness signal is process-local, so it applies to in-process children only.
|
|
58
|
+
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) cannot observe its parent's polling and still waits the full timeout.
|
|
59
|
+
|
|
35
60
|
---
|
|
36
61
|
|
|
37
62
|
## Coexistence with Other Subagent Extensions
|
package/package.json
CHANGED
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
"default": true,
|
|
32
32
|
"type": "boolean"
|
|
33
33
|
},
|
|
34
|
+
"forwardingTimeoutMs": {
|
|
35
|
+
"description": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
36
|
+
"markdownDescription": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
|
|
37
|
+
"default": 600000,
|
|
38
|
+
"type": "integer",
|
|
39
|
+
"minimum": 1,
|
|
40
|
+
"maximum": 9007199254740991
|
|
41
|
+
},
|
|
34
42
|
"toolInputPreviewMaxLength": {
|
|
35
43
|
"description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
|
|
36
44
|
"markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
2
|
+
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Node types whose subtrees must never be descended into for
|
|
@@ -29,6 +30,8 @@ export const ARG_NODE_TYPES = new Set([
|
|
|
29
30
|
* - `raw_string` → strip surrounding single quotes
|
|
30
31
|
* - `string` → strip surrounding double quotes, concatenate children text
|
|
31
32
|
* - `concatenation` → concatenate resolved children
|
|
33
|
+
* - expansions → the resolved value of a plain `$HOME`/`$PWD` reference,
|
|
34
|
+
* else `.text` (see `shell-variable-expansion.ts`)
|
|
32
35
|
* - other → `.text` as fallback
|
|
33
36
|
*/
|
|
34
37
|
export function resolveNodeText(node: TSNode): string {
|
|
@@ -57,9 +60,10 @@ export function resolveNodeText(node: TSNode): string {
|
|
|
57
60
|
return result;
|
|
58
61
|
}
|
|
59
62
|
case "string_content":
|
|
63
|
+
return node.text;
|
|
60
64
|
case "simple_expansion":
|
|
61
65
|
case "expansion":
|
|
62
|
-
return node.text;
|
|
66
|
+
return resolvePlainVariableExpansion(node) ?? node.text;
|
|
63
67
|
case "concatenation": {
|
|
64
68
|
let result = "";
|
|
65
69
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolution of the shell variable references the bash path projection can
|
|
3
|
+
* settle statically.
|
|
4
|
+
*
|
|
5
|
+
* Runs at token collection, upstream of classification: by the time a token
|
|
6
|
+
* reaches `classifyTokenAsPathCandidate` it already carries the expanded path,
|
|
7
|
+
* so `$HOME/x` is accepted by the ordinary absolute-shape branch and needs no
|
|
8
|
+
* per-variable knowledge in the classifiers (#694). Keeping the vocabulary here
|
|
9
|
+
* — rather than teaching each classifier a `$HOME` prefix — is what stops the
|
|
10
|
+
* two from drifting apart, which is the defect this module closes.
|
|
11
|
+
*
|
|
12
|
+
* The resolvable set is deliberately tiny and closed. `HOME` is the spelling
|
|
13
|
+
* `expandHomePath` already resolves for config patterns and path literals, so
|
|
14
|
+
* resolving it here removes an inconsistency rather than widening the
|
|
15
|
+
* determinism boundary; `PWD` reads no environment at all. Every other name
|
|
16
|
+
* keeps its literal text, so ADR 0003's exclusion of ambient host state stands.
|
|
17
|
+
* See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
|
|
18
|
+
*/
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
|
|
21
|
+
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
|
|
25
|
+
* is not a plain reference or names a variable outside the resolvable set.
|
|
26
|
+
*
|
|
27
|
+
* Plainness is decided structurally, not by matching the node's text: a plain
|
|
28
|
+
* reference carries exactly one `variable_name` child and nothing else but
|
|
29
|
+
* delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
|
|
30
|
+
* carries additional children and is therefore rejected without this module
|
|
31
|
+
* needing to enumerate bash's expansion operators.
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePlainVariableExpansion(node: TSNode): string | null {
|
|
34
|
+
const name = plainVariableName(node);
|
|
35
|
+
return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How each resolvable variable is spelled as a path.
|
|
40
|
+
*
|
|
41
|
+
* `PWD` resolves to the base-relative marker rather than a directory: the
|
|
42
|
+
* shell's working directory at a given point *is* the projection's effective
|
|
43
|
+
* base, which the resolver already applies via `resolveBase`. Handing back `.`
|
|
44
|
+
* therefore lands `$PWD/x` on the same footing as `./x` — correct after any
|
|
45
|
+
* `cd` folding, conservative under an unknown base (#393), and free of both a
|
|
46
|
+
* threaded base parameter and a platform branch.
|
|
47
|
+
*/
|
|
48
|
+
const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
|
|
49
|
+
["HOME", homedir],
|
|
50
|
+
["PWD", () => "."],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Node types that delimit an expansion without altering what it evaluates to. */
|
|
54
|
+
const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The variable a node plainly references, or `null` when it references none —
|
|
58
|
+
* because it has no `variable_name` child, has more than one, or carries a
|
|
59
|
+
* child that is neither the name nor a delimiter (an expansion operator and its
|
|
60
|
+
* operand, or an assignment's `=` and value).
|
|
61
|
+
*/
|
|
62
|
+
function plainVariableName(node: TSNode): string | null {
|
|
63
|
+
let name: string | null = null;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
66
|
+
const child = node.child(i);
|
|
67
|
+
if (!child) continue;
|
|
68
|
+
if (child.type === "variable_name") {
|
|
69
|
+
if (name !== null) return null;
|
|
70
|
+
name = child.text;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!EXPANSION_DELIMITERS.has(child.type)) return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
@@ -26,11 +26,13 @@ import {
|
|
|
26
26
|
type ForwardedPromptDisplay,
|
|
27
27
|
type ForwardedSessionApproval,
|
|
28
28
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
29
|
-
|
|
29
|
+
PERMISSION_FORWARDING_SERVING_GRACE_MS,
|
|
30
30
|
type PermissionForwardingLocation,
|
|
31
|
-
|
|
31
|
+
type PermissionForwardingTarget,
|
|
32
|
+
resolvePermissionForwardingTarget,
|
|
32
33
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
33
34
|
} from "#src/authority/permission-forwarding";
|
|
35
|
+
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
34
36
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
35
37
|
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
36
38
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
@@ -85,9 +87,30 @@ export interface ParentAuthorizerDeps {
|
|
|
85
87
|
forwardingDir: string;
|
|
86
88
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
87
89
|
registry?: SubagentSessionRegistry;
|
|
90
|
+
/** Whether the resolved target is draining its inbox (in-process targets only). */
|
|
91
|
+
serving: ServingLookup;
|
|
92
|
+
/** How long to wait for the target's answer, read live so config edits apply. */
|
|
93
|
+
getTimeoutMs: () => number;
|
|
88
94
|
logger: DebugReviewLogger;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Deny because no authority ever ruled — the request was never delivered,
|
|
99
|
+
* never answered, or answered unreadably.
|
|
100
|
+
*
|
|
101
|
+
* `confirmationUnavailable` is what keeps this out of the "User denied …"
|
|
102
|
+
* message (#719): a user who was never asked denied nothing. `denialReason`
|
|
103
|
+
* names which path gave up, and the gate renders it to the model.
|
|
104
|
+
*/
|
|
105
|
+
function abandon(denialReason: string): PermissionPromptDecision {
|
|
106
|
+
return {
|
|
107
|
+
approved: false,
|
|
108
|
+
state: "denied",
|
|
109
|
+
confirmationUnavailable: true,
|
|
110
|
+
denialReason,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
/**
|
|
92
115
|
* Authorizer for a subagent session: escalate the ask up the tree to the
|
|
93
116
|
* parent's authority.
|
|
@@ -103,6 +126,8 @@ export interface ParentAuthorizerDeps {
|
|
|
103
126
|
export class ParentAuthorizer implements TerminalAuthorizer {
|
|
104
127
|
private readonly forwardingDir: string;
|
|
105
128
|
private readonly registry: SubagentSessionRegistry | undefined;
|
|
129
|
+
private readonly serving: ServingLookup;
|
|
130
|
+
private readonly getTimeoutMs: () => number;
|
|
106
131
|
private readonly logger: DebugReviewLogger;
|
|
107
132
|
|
|
108
133
|
constructor(
|
|
@@ -111,6 +136,8 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
111
136
|
) {
|
|
112
137
|
this.forwardingDir = deps.forwardingDir;
|
|
113
138
|
this.registry = deps.registry;
|
|
139
|
+
this.serving = deps.serving;
|
|
140
|
+
this.getTimeoutMs = deps.getTimeoutMs;
|
|
114
141
|
this.logger = deps.logger;
|
|
115
142
|
}
|
|
116
143
|
|
|
@@ -137,7 +164,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
137
164
|
facts: ForwardedRequestFacts,
|
|
138
165
|
): Promise<PermissionPromptDecision> {
|
|
139
166
|
const requesterSessionId = getSessionId(ctx);
|
|
140
|
-
const
|
|
167
|
+
const target = resolvePermissionForwardingTarget({
|
|
141
168
|
hasUI: ctx.hasUI,
|
|
142
169
|
// Invariant: selectAuthorizer only selects ParentAuthorizer for a
|
|
143
170
|
// no-UI subagent context, so this is always true — no detection dep
|
|
@@ -149,7 +176,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
149
176
|
registry: this.registry,
|
|
150
177
|
});
|
|
151
178
|
|
|
152
|
-
if (!
|
|
179
|
+
if (!target) {
|
|
153
180
|
logPermissionForwardingError(
|
|
154
181
|
this.logger,
|
|
155
182
|
`Permission forwarding target session could not be resolved. ` +
|
|
@@ -158,27 +185,31 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
158
185
|
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
|
|
159
186
|
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
|
|
160
187
|
);
|
|
161
|
-
return
|
|
188
|
+
return abandon(
|
|
189
|
+
"Could not resolve a parent session to forward this permission request to",
|
|
190
|
+
);
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
const location = ensurePermissionForwardingLocation(
|
|
165
194
|
this.logger,
|
|
166
195
|
this.forwardingDir,
|
|
167
|
-
|
|
196
|
+
target.sessionId,
|
|
168
197
|
);
|
|
169
198
|
if (!location) {
|
|
170
199
|
logPermissionForwardingError(
|
|
171
200
|
this.logger,
|
|
172
|
-
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${
|
|
201
|
+
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${target.sessionId}'`,
|
|
202
|
+
);
|
|
203
|
+
return abandon(
|
|
204
|
+
`Permission forwarding directories could not be prepared for session '${target.sessionId}'`,
|
|
173
205
|
);
|
|
174
|
-
return { approved: false, state: "denied" };
|
|
175
206
|
}
|
|
176
207
|
|
|
177
208
|
const request = this.buildForwardedRequest(
|
|
178
209
|
ctx,
|
|
179
210
|
facts,
|
|
180
211
|
requesterSessionId,
|
|
181
|
-
|
|
212
|
+
target.sessionId,
|
|
182
213
|
);
|
|
183
214
|
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
184
215
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
@@ -187,7 +218,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
187
218
|
requestId: request.id,
|
|
188
219
|
requesterAgentName: request.requesterAgentName,
|
|
189
220
|
requesterSessionId: request.requesterSessionId,
|
|
190
|
-
targetSessionId,
|
|
221
|
+
targetSessionId: target.sessionId,
|
|
191
222
|
requestPath,
|
|
192
223
|
responsePath,
|
|
193
224
|
});
|
|
@@ -200,7 +231,8 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
200
231
|
`Failed to write forwarded permission request '${requestPath}'`,
|
|
201
232
|
error,
|
|
202
233
|
);
|
|
203
|
-
|
|
234
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
235
|
+
return abandon("The forwarded permission request could not be written");
|
|
204
236
|
}
|
|
205
237
|
|
|
206
238
|
return this.pollForForwardedResponse(
|
|
@@ -208,6 +240,7 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
208
240
|
request,
|
|
209
241
|
requestPath,
|
|
210
242
|
responsePath,
|
|
243
|
+
target,
|
|
211
244
|
);
|
|
212
245
|
}
|
|
213
246
|
|
|
@@ -262,9 +295,12 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
262
295
|
request: ForwardedPermissionRequest,
|
|
263
296
|
requestPath: string,
|
|
264
297
|
responsePath: string,
|
|
298
|
+
target: PermissionForwardingTarget,
|
|
265
299
|
): Promise<PermissionPromptDecision> {
|
|
266
300
|
const { id: requestId, requesterAgentName, targetSessionId } = request;
|
|
267
|
-
const
|
|
301
|
+
const timeoutMs = this.getTimeoutMs();
|
|
302
|
+
const deadline = Date.now() + timeoutMs;
|
|
303
|
+
let unservedSince: number | null = null;
|
|
268
304
|
|
|
269
305
|
while (Date.now() < deadline) {
|
|
270
306
|
if (existsSync(responsePath)) {
|
|
@@ -281,18 +317,28 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
281
317
|
targetSessionId,
|
|
282
318
|
responsePath,
|
|
283
319
|
});
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
"
|
|
320
|
+
this.discardRequest(location, requestPath, responsePath);
|
|
321
|
+
return (
|
|
322
|
+
response ??
|
|
323
|
+
abandon("The parent session's permission response could not be read")
|
|
288
324
|
);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
unservedSince = this.checkServingLiveness(target, unservedSince);
|
|
328
|
+
if (
|
|
329
|
+
unservedSince !== null &&
|
|
330
|
+
Date.now() - unservedSince >= PERMISSION_FORWARDING_SERVING_GRACE_MS
|
|
331
|
+
) {
|
|
332
|
+
this.logger.review("forwarded_permission.no_serving_session", {
|
|
333
|
+
requestId,
|
|
334
|
+
requesterSessionId: request.requesterSessionId,
|
|
335
|
+
targetSessionId,
|
|
336
|
+
servingSessionIds: this.serving.servingIds(),
|
|
337
|
+
});
|
|
338
|
+
this.discardRequest(location, requestPath);
|
|
339
|
+
return abandon(
|
|
340
|
+
`Session '${target.sessionId}' is not serving forwarded permission requests`,
|
|
293
341
|
);
|
|
294
|
-
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
295
|
-
return response ?? { approved: false, state: "denied" };
|
|
296
342
|
}
|
|
297
343
|
|
|
298
344
|
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
@@ -308,8 +354,51 @@ export class ParentAuthorizer implements TerminalAuthorizer {
|
|
|
308
354
|
targetSessionId,
|
|
309
355
|
responsePath,
|
|
310
356
|
});
|
|
357
|
+
this.discardRequest(location, requestPath);
|
|
358
|
+
return abandon(
|
|
359
|
+
`Session '${target.sessionId}' did not answer within ${timeoutMs / 1000}s`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Track how long the target has looked unserved, or `null` while it looks fine.
|
|
365
|
+
*
|
|
366
|
+
* Only an in-process target (`source: "registry"`) can be judged: a target in
|
|
367
|
+
* another process shares no serving registry with this one, so its absence
|
|
368
|
+
* from the registry says nothing (#719, follow-up in #721).
|
|
369
|
+
*/
|
|
370
|
+
private checkServingLiveness(
|
|
371
|
+
target: PermissionForwardingTarget,
|
|
372
|
+
unservedSince: number | null,
|
|
373
|
+
): number | null {
|
|
374
|
+
if (target.source !== "registry") {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
if (this.serving.isServing(target.sessionId)) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
return unservedSince ?? Date.now();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Drop this exchange's files and, if nothing else is pending, its directories.
|
|
385
|
+
*
|
|
386
|
+
* Deleting the request is what makes an abandonment final: a request left
|
|
387
|
+
* behind would be answered by the parent long after the child gave up.
|
|
388
|
+
*/
|
|
389
|
+
private discardRequest(
|
|
390
|
+
location: PermissionForwardingLocation,
|
|
391
|
+
requestPath: string,
|
|
392
|
+
responsePath?: string,
|
|
393
|
+
): void {
|
|
394
|
+
if (responsePath) {
|
|
395
|
+
safeDeleteFile(
|
|
396
|
+
this.logger,
|
|
397
|
+
responsePath,
|
|
398
|
+
"forwarded permission response",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
311
401
|
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
|
|
312
402
|
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
313
|
-
return { approved: false, state: "denied" };
|
|
314
403
|
}
|
|
315
404
|
}
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
PromptPreferences,
|
|
5
5
|
requestPermissionDecision,
|
|
6
6
|
} from "#src/authority/permission-prompt-component";
|
|
7
|
+
import type { ServingLookup } from "#src/authority/serving-registry";
|
|
7
8
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
8
9
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
9
10
|
import type { AuthorizerLog, PermissionQuery } from "#src/service";
|
|
@@ -72,6 +73,10 @@ export interface AuthorizerSelectionDeps {
|
|
|
72
73
|
forwardingDir: string;
|
|
73
74
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
74
75
|
registry?: SubagentSessionRegistry;
|
|
76
|
+
/** Which sessions are draining a forwarded-permission inbox. */
|
|
77
|
+
servingRegistry: ServingLookup;
|
|
78
|
+
/** The forwarding timeout, read live so a config edit applies to the next ask. */
|
|
79
|
+
getForwardingTimeoutMs: () => number;
|
|
75
80
|
logger: DebugReviewLogger;
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -100,6 +105,8 @@ export function selectAuthorizer(
|
|
|
100
105
|
return new ParentAuthorizer(ctx, {
|
|
101
106
|
forwardingDir: deps.forwardingDir,
|
|
102
107
|
registry: deps.registry,
|
|
108
|
+
serving: deps.servingRegistry,
|
|
109
|
+
getTimeoutMs: deps.getForwardingTimeoutMs,
|
|
103
110
|
logger: deps.logger,
|
|
104
111
|
});
|
|
105
112
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
2
3
|
import type { InboxProcessor } from "./forwarded-request-server";
|
|
4
|
+
import { getSessionId } from "./forwarder-context";
|
|
3
5
|
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
6
|
+
import type { ServingAnnouncer } from "./serving-registry";
|
|
4
7
|
import type { SubagentDetector } from "./subagent-detection";
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -12,6 +15,17 @@ export interface ForwardingController {
|
|
|
12
15
|
stop(): void;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
/** Constructor config for {@link ForwardingManager}. */
|
|
19
|
+
export interface ForwardingManagerDeps {
|
|
20
|
+
/** Single owner of subagent detection; gates whether this session may serve. */
|
|
21
|
+
detection: SubagentDetector;
|
|
22
|
+
/** Drains this session's forwarded-permission inbox on each tick. */
|
|
23
|
+
forwarder: InboxProcessor;
|
|
24
|
+
/** Publishes that this session is draining its inbox, for forwarding children. */
|
|
25
|
+
serving: ServingAnnouncer;
|
|
26
|
+
logger: DebugReviewLogger;
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
/**
|
|
16
30
|
* Encapsulates the forwarded-permission polling lifecycle.
|
|
17
31
|
*
|
|
@@ -19,16 +33,20 @@ export interface ForwardingController {
|
|
|
19
33
|
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
|
|
20
34
|
* session event that may activate forwarding; call `stop()` on session
|
|
21
35
|
* shutdown.
|
|
36
|
+
*
|
|
37
|
+
* While polling, it publishes the session id it polls to the `ServingAnnouncer`
|
|
38
|
+
* so a forwarding child can tell that someone is draining the inbox it wrote
|
|
39
|
+
* into — and the review log records that id, so a child forwarding to a
|
|
40
|
+
* *different* id is visible as a one-line diff against its
|
|
41
|
+
* `forwarded_permission.request_created` entry (#719).
|
|
22
42
|
*/
|
|
23
43
|
export class ForwardingManager {
|
|
24
44
|
private timer: NodeJS.Timeout | null = null;
|
|
25
45
|
private context: ExtensionContext | null = null;
|
|
26
46
|
private processing = false;
|
|
47
|
+
private servingSessionId: string | null = null;
|
|
27
48
|
|
|
28
|
-
constructor(
|
|
29
|
-
private readonly detection: SubagentDetector,
|
|
30
|
-
private readonly forwarder: InboxProcessor,
|
|
31
|
-
) {}
|
|
49
|
+
constructor(private readonly deps: ForwardingManagerDeps) {}
|
|
32
50
|
|
|
33
51
|
/**
|
|
34
52
|
* Start polling if `ctx` has UI and is not a subagent execution context.
|
|
@@ -37,11 +55,12 @@ export class ForwardingManager {
|
|
|
37
55
|
* Stops any existing poll when the context does not qualify for forwarding.
|
|
38
56
|
*/
|
|
39
57
|
start(ctx: ExtensionContext): void {
|
|
40
|
-
if (!ctx.hasUI || this.detection.isSubagent(ctx)) {
|
|
58
|
+
if (!ctx.hasUI || this.deps.detection.isSubagent(ctx)) {
|
|
41
59
|
this.stop();
|
|
42
60
|
return;
|
|
43
61
|
}
|
|
44
62
|
this.context = ctx;
|
|
63
|
+
this.announceServing(getSessionId(ctx));
|
|
45
64
|
if (this.timer) {
|
|
46
65
|
return;
|
|
47
66
|
}
|
|
@@ -50,7 +69,7 @@ export class ForwardingManager {
|
|
|
50
69
|
return;
|
|
51
70
|
}
|
|
52
71
|
this.processing = true;
|
|
53
|
-
void this.forwarder.processInbox(this.context).finally(() => {
|
|
72
|
+
void this.deps.forwarder.processInbox(this.context).finally(() => {
|
|
54
73
|
this.processing = false;
|
|
55
74
|
});
|
|
56
75
|
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
@@ -62,7 +81,42 @@ export class ForwardingManager {
|
|
|
62
81
|
clearInterval(this.timer);
|
|
63
82
|
this.timer = null;
|
|
64
83
|
}
|
|
84
|
+
this.withdrawServing();
|
|
65
85
|
this.context = null;
|
|
66
86
|
this.processing = false;
|
|
67
87
|
}
|
|
88
|
+
|
|
89
|
+
// ── Private methods ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Publish `sessionId` as the served session, replacing any previous one.
|
|
93
|
+
*
|
|
94
|
+
* A no-op when the id is unchanged, since `start` runs on every
|
|
95
|
+
* `before_agent_start`, `input`, and `tool_call` — the announcement must not
|
|
96
|
+
* cost a log line per turn.
|
|
97
|
+
*/
|
|
98
|
+
private announceServing(sessionId: string): void {
|
|
99
|
+
if (this.servingSessionId === sessionId) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.withdrawServing();
|
|
103
|
+
this.servingSessionId = sessionId;
|
|
104
|
+
this.deps.serving.markServing(sessionId);
|
|
105
|
+
this.deps.logger.review("forwarded_permission.serving_started", {
|
|
106
|
+
sessionId,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Withdraw the published session, if any. */
|
|
111
|
+
private withdrawServing(): void {
|
|
112
|
+
const sessionId = this.servingSessionId;
|
|
113
|
+
if (sessionId === null) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.servingSessionId = null;
|
|
117
|
+
this.deps.serving.clearServing(sessionId);
|
|
118
|
+
this.deps.logger.review("forwarded_permission.serving_stopped", {
|
|
119
|
+
sessionId,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
68
122
|
}
|
|
@@ -16,11 +16,14 @@ export type PermissionPromptDecision = {
|
|
|
16
16
|
*/
|
|
17
17
|
autoApproved?: true;
|
|
18
18
|
/**
|
|
19
|
-
* True when no
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* True when no human ever ruled on this ask: either no live authority was
|
|
20
|
+
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
|
|
21
|
+
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
|
|
22
|
+
* unresolvable, request undeliverable, target not serving, or no answer
|
|
23
|
+
* within the timeout). Consumed by deriveResolution (the decision-event
|
|
24
|
+
* resolution), the gate (block reason), and PermissionPrompter (review-entry
|
|
25
|
+
* resolution) to emit "confirmation_unavailable" rather than a plain user
|
|
26
|
+
* denial — a user who was never asked denied nothing (#719).
|
|
24
27
|
*/
|
|
25
28
|
confirmationUnavailable?: true;
|
|
26
29
|
};
|
|
@@ -5,6 +5,17 @@ import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
|
5
5
|
|
|
6
6
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
7
7
|
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
+
/**
|
|
9
|
+
* How long an in-process forwarding target may go unserved before the child
|
|
10
|
+
* gives up on it — eight poll ticks.
|
|
11
|
+
*
|
|
12
|
+
* A window rather than a single check because `ForwardingManager` withdraws and
|
|
13
|
+
* re-announces across a session switch, and a request that arrives inside that
|
|
14
|
+
* gap is about to be picked up. Not configurable: the operator-facing knob is
|
|
15
|
+
* the overall timeout, and this only decides how fast a hopeless wait ends.
|
|
16
|
+
*/
|
|
17
|
+
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
|
|
18
|
+
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
|
8
19
|
export const SUBAGENT_ENV_HINT_KEYS = [
|
|
9
20
|
// pi-agent-router (original)
|
|
10
21
|
"PI_IS_SUBAGENT",
|
|
@@ -204,7 +215,25 @@ export function createPermissionForwardingLocation(
|
|
|
204
215
|
};
|
|
205
216
|
}
|
|
206
217
|
|
|
207
|
-
|
|
218
|
+
/**
|
|
219
|
+
* How a forwarding target was resolved.
|
|
220
|
+
*
|
|
221
|
+
* `"registry"` is the load-bearing value: it means the requester is an
|
|
222
|
+
* **in-process** child of `sessionId`, so the two share a `globalThis` and the
|
|
223
|
+
* requester may consult the serving-session registry to decide whether anyone
|
|
224
|
+
* is draining its inbox. `"env"` means the target lives in another process,
|
|
225
|
+
* where that signal is unavailable; `"self"` is the UI host owning its own
|
|
226
|
+
* forwarding location.
|
|
227
|
+
*/
|
|
228
|
+
export type PermissionForwardingTargetSource = "self" | "registry" | "env";
|
|
229
|
+
|
|
230
|
+
/** The resolved forwarding target together with how it was found. */
|
|
231
|
+
export interface PermissionForwardingTarget {
|
|
232
|
+
sessionId: string;
|
|
233
|
+
source: PermissionForwardingTargetSource;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function resolvePermissionForwardingTarget(options: {
|
|
208
237
|
hasUI: boolean;
|
|
209
238
|
isSubagent: boolean;
|
|
210
239
|
currentSessionId?: string | null;
|
|
@@ -213,9 +242,12 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
213
242
|
sessionId?: string;
|
|
214
243
|
/** In-process subagent session registry (checked before env vars). */
|
|
215
244
|
registry?: SubagentSessionRegistry;
|
|
216
|
-
}):
|
|
245
|
+
}): PermissionForwardingTarget | null {
|
|
217
246
|
if (options.hasUI) {
|
|
218
|
-
|
|
247
|
+
const own = normalizePermissionForwardingSessionId(
|
|
248
|
+
options.currentSessionId,
|
|
249
|
+
);
|
|
250
|
+
return own === null ? null : { sessionId: own, source: "self" };
|
|
219
251
|
}
|
|
220
252
|
|
|
221
253
|
if (!options.isSubagent) {
|
|
@@ -228,14 +260,14 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
228
260
|
const resolved = normalizePermissionForwardingSessionId(
|
|
229
261
|
entry?.parentSessionId,
|
|
230
262
|
);
|
|
231
|
-
if (resolved) return resolved;
|
|
263
|
+
if (resolved) return { sessionId: resolved, source: "registry" };
|
|
232
264
|
}
|
|
233
265
|
|
|
234
266
|
// 2. Env vars — process-based subagent extensions.
|
|
235
267
|
const env = options.env ?? process.env;
|
|
236
268
|
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
|
237
269
|
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
|
238
|
-
if (resolved) return resolved;
|
|
270
|
+
if (resolved) return { sessionId: resolved, source: "env" };
|
|
239
271
|
}
|
|
240
272
|
return null;
|
|
241
273
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serving-registry.ts — Which sessions are draining a forwarded-permission inbox.
|
|
3
|
+
*
|
|
4
|
+
* A session with a UI that is not itself a subagent polls its own
|
|
5
|
+
* `<forwardingDir>/sessions/<id>/requests/` directory (see `ForwardingManager`)
|
|
6
|
+
* and answers whatever a child forwards to it. Nothing else in the process can
|
|
7
|
+
* observe that, so a child whose parent is *not* polling has no way to tell
|
|
8
|
+
* "a human is being asked" from "nobody is home", and waits out the full
|
|
9
|
+
* forwarding timeout before denying (#719).
|
|
10
|
+
*
|
|
11
|
+
* This registry publishes that fact: the polling session marks itself while it
|
|
12
|
+
* polls, and a forwarding child checks whether its resolved target is marked.
|
|
13
|
+
*
|
|
14
|
+
* The single instance is stored on `globalThis` (via `Symbol.for()`) for the
|
|
15
|
+
* same reason `SubagentSessionRegistry` is: each session's `ResourceLoader`
|
|
16
|
+
* creates its own jiti instance and its own event bus, so the parent's
|
|
17
|
+
* permission-system instance and an in-process child's instance share no
|
|
18
|
+
* module state — only process globals. See `getServingSessionRegistry()`.
|
|
19
|
+
*
|
|
20
|
+
* The signal is meaningful only for an **in-process** child (one that resolved
|
|
21
|
+
* its target through `SubagentSessionRegistry`, i.e. a forwarding target with
|
|
22
|
+
* `source: "registry"`). A child in another process shares no `globalThis` with
|
|
23
|
+
* its parent and must not read anything into an absent mark.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Process-global key for the shared registry slot. Exported for test teardown. */
|
|
27
|
+
export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
|
|
28
|
+
"@gotgenes/pi-permission-system:serving-registry",
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Announce-side seam: the polling session marks and clears itself.
|
|
33
|
+
*
|
|
34
|
+
* `ForwardingManager` depends on this rather than the concrete registry so it
|
|
35
|
+
* neither reads the store nor gains a query it has no business making (ISP).
|
|
36
|
+
*/
|
|
37
|
+
export interface ServingAnnouncer {
|
|
38
|
+
markServing(sessionId: string): void;
|
|
39
|
+
clearServing(sessionId: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Query-side seam: a forwarding child asks whether its target is draining.
|
|
44
|
+
*
|
|
45
|
+
* `servingIds()` exists for the diagnostic review entry a child writes when it
|
|
46
|
+
* abandons an unserved request — the mismatch between the id it forwarded to
|
|
47
|
+
* and the ids actually being served is the whole diagnosis.
|
|
48
|
+
*/
|
|
49
|
+
export interface ServingLookup {
|
|
50
|
+
isServing(sessionId: string): boolean;
|
|
51
|
+
servingIds(): readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Registry of sessions currently draining a forwarded-permission inbox.
|
|
56
|
+
*
|
|
57
|
+
* A process-global singleton — obtain it via {@link getServingSessionRegistry},
|
|
58
|
+
* never `new` (see that accessor for why). Written exclusively by the owning
|
|
59
|
+
* session's `ForwardingManager`, keyed by that session's own id, so one
|
|
60
|
+
* session's shutdown cannot clear another's mark.
|
|
61
|
+
*
|
|
62
|
+
* A mark left behind by a session that died without `session_shutdown` makes a
|
|
63
|
+
* child wait out the full timeout instead of abandoning early — the same
|
|
64
|
+
* behavior as before this signal existed, which is the safe direction to fail.
|
|
65
|
+
*/
|
|
66
|
+
export class ServingSessionRegistry implements ServingAnnouncer, ServingLookup {
|
|
67
|
+
private readonly serving = new Set<string>();
|
|
68
|
+
|
|
69
|
+
/** Record that `sessionId` is polling its inbox. Idempotent. */
|
|
70
|
+
markServing(sessionId: string): void {
|
|
71
|
+
this.serving.add(sessionId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Record that `sessionId` has stopped polling. No-op if unmarked. */
|
|
75
|
+
clearServing(sessionId: string): void {
|
|
76
|
+
this.serving.delete(sessionId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Return `true` when `sessionId` is currently polling its inbox. */
|
|
80
|
+
isServing(sessionId: string): boolean {
|
|
81
|
+
return this.serving.has(sessionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every currently-serving session id, for diagnostics. */
|
|
85
|
+
servingIds(): readonly string[] {
|
|
86
|
+
return [...this.serving];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the process-global ServingSessionRegistry, creating it on first call.
|
|
92
|
+
*
|
|
93
|
+
* Intentionally has no teardown hook: a child's `session_shutdown` must not be
|
|
94
|
+
* able to wipe the parent's mark. Entries are added and removed exclusively by
|
|
95
|
+
* the owning session's `ForwardingManager`.
|
|
96
|
+
*/
|
|
97
|
+
export function getServingSessionRegistry(): ServingSessionRegistry {
|
|
98
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
99
|
+
const existing = store[SERVING_SESSION_REGISTRY_KEY] as
|
|
100
|
+
| ServingSessionRegistry
|
|
101
|
+
| undefined;
|
|
102
|
+
if (existing) {
|
|
103
|
+
return existing;
|
|
104
|
+
}
|
|
105
|
+
const registry = new ServingSessionRegistry();
|
|
106
|
+
store[SERVING_SESSION_REGISTRY_KEY] = registry;
|
|
107
|
+
return registry;
|
|
108
|
+
}
|
package/src/config-loader.ts
CHANGED
package/src/config-schema.ts
CHANGED
|
@@ -187,6 +187,13 @@ export const unifiedConfigSchema = z
|
|
|
187
187
|
"Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
|
|
188
188
|
default: true,
|
|
189
189
|
}),
|
|
190
|
+
forwardingTimeoutMs: z.number().int().min(1).optional().meta({
|
|
191
|
+
description:
|
|
192
|
+
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
193
|
+
markdownDescription:
|
|
194
|
+
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
|
|
195
|
+
default: 600000,
|
|
196
|
+
}),
|
|
190
197
|
toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
|
|
191
198
|
description:
|
|
192
199
|
"Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
|
package/src/denial-messages.ts
CHANGED
|
@@ -76,9 +76,19 @@ export function formatDenyReason(ctx: DenialContext): string {
|
|
|
76
76
|
return `${EXTENSION_TAG} ${buildDenyBody(ctx)}`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Format the block reason when no live authority could answer the ask.
|
|
81
|
+
*
|
|
82
|
+
* `denialReason` is optional because the plain no-UI case has nothing to add;
|
|
83
|
+
* an authority that abandoned for a specific reason (a forwarding target that
|
|
84
|
+
* is not serving, a request that could not be written) supplies one, and the
|
|
85
|
+
* model sees it (#719).
|
|
86
|
+
*/
|
|
87
|
+
export function formatUnavailableReason(
|
|
88
|
+
ctx: DenialContext,
|
|
89
|
+
denialReason?: string,
|
|
90
|
+
): string {
|
|
91
|
+
return `${EXTENSION_TAG} ${buildUnavailableBody(ctx, denialReason)}`;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/** Format the block reason when the user denies at an interactive prompt. */
|
|
@@ -187,7 +197,14 @@ export function matchQualifier(
|
|
|
187
197
|
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
|
188
198
|
}
|
|
189
199
|
|
|
190
|
-
function buildUnavailableBody(
|
|
200
|
+
function buildUnavailableBody(
|
|
201
|
+
ctx: DenialContext,
|
|
202
|
+
denialReason?: string,
|
|
203
|
+
): string {
|
|
204
|
+
return `${buildUnavailableSentence(ctx)}${reasonSuffix(denialReason)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildUnavailableSentence(ctx: DenialContext): string {
|
|
191
208
|
switch (ctx.kind) {
|
|
192
209
|
case "tool": {
|
|
193
210
|
const { check } = ctx;
|
package/src/expand-home.ts
CHANGED
|
@@ -2,27 +2,41 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* The spellings of the home directory this package resolves, in every pattern
|
|
6
|
+
* and path literal.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
* `$HOME` and `${HOME}` are the two spellings of the same shell variable and
|
|
9
|
+
* must stay interchangeable: a rule keyed on one form has to match a path
|
|
10
|
+
* written in the other, and the bash path projection classifies a token by the
|
|
11
|
+
* shape it has *after* this expansion (#694).
|
|
12
|
+
*/
|
|
13
|
+
const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Expand a home-directory prefix in a pattern or path value to the OS home
|
|
17
|
+
* directory.
|
|
18
|
+
*
|
|
19
|
+
* A prefix is recognized only when it stands alone or is followed by a path
|
|
20
|
+
* separator, so a longer name (`~username`, `$HOMEDIR`, `${HOMEDIR}`) and a
|
|
21
|
+
* braced parameter expansion carrying an operator (`${HOME:-/tmp}`,
|
|
22
|
+
* `${HOME%/*}`) are both left untouched.
|
|
23
|
+
*
|
|
24
|
+
* Supported forms, for each prefix in {@link HOME_PREFIXES}:
|
|
25
|
+
* - `<prefix>` → `homedir()`
|
|
26
|
+
* - `<prefix>/path` → `homedir()/path`
|
|
27
|
+
* - `<prefix>\path` → `homedir()\path` (Windows)
|
|
14
28
|
*
|
|
15
29
|
* All other patterns are returned unchanged.
|
|
16
30
|
*/
|
|
17
31
|
export function expandHomePath(pattern: string): string {
|
|
18
|
-
|
|
19
|
-
return homedir();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
for (const prefix of HOME_PREFIXES) {
|
|
33
|
+
if (pattern === prefix) return homedir();
|
|
34
|
+
if (!pattern.startsWith(prefix)) continue;
|
|
35
|
+
|
|
36
|
+
const rest = pattern.slice(prefix.length);
|
|
37
|
+
if (rest.startsWith("/") || rest.startsWith("\\")) {
|
|
38
|
+
return join(homedir(), rest.slice(1));
|
|
39
|
+
}
|
|
26
40
|
}
|
|
27
41
|
return pattern;
|
|
28
42
|
}
|
package/src/extension-config.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
20
20
|
doublePressToConfirm: boolean;
|
|
21
21
|
/** Additional directories to auto-allow for reads as Pi infrastructure. */
|
|
22
22
|
piInfrastructureReadPaths?: string[];
|
|
23
|
+
/** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
|
|
24
|
+
forwardingTimeoutMs?: number;
|
|
23
25
|
/** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
|
|
24
26
|
toolInputPreviewMaxLength?: number;
|
|
25
27
|
/** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
|
|
@@ -71,6 +73,9 @@ export function normalizePermissionSystemConfig(
|
|
|
71
73
|
if (raw.piInfrastructureReadPaths !== undefined) {
|
|
72
74
|
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
|
|
73
75
|
}
|
|
76
|
+
if (raw.forwardingTimeoutMs !== undefined) {
|
|
77
|
+
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
|
78
|
+
}
|
|
74
79
|
if (raw.toolInputPreviewMaxLength !== undefined) {
|
|
75
80
|
result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
|
|
76
81
|
}
|
|
@@ -133,7 +133,11 @@ export class GateRunner {
|
|
|
133
133
|
// Construct messages from the centralized formatter.
|
|
134
134
|
const messages = {
|
|
135
135
|
denyReason: formatDenyReason(descriptor.denialContext),
|
|
136
|
-
unavailableReason:
|
|
136
|
+
unavailableReason: (decision: PermissionPromptDecision) =>
|
|
137
|
+
formatUnavailableReason(
|
|
138
|
+
descriptor.denialContext,
|
|
139
|
+
decision.denialReason,
|
|
140
|
+
),
|
|
137
141
|
userDeniedReason: (decision: PermissionPromptDecision) =>
|
|
138
142
|
formatUserDeniedReason(descriptor.denialContext, decision.denialReason),
|
|
139
143
|
};
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
type ServingPolicy,
|
|
10
10
|
} from "./authority/forwarded-request-server";
|
|
11
11
|
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
12
|
+
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
|
|
12
13
|
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
|
13
14
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
15
|
+
import { getServingSessionRegistry } from "./authority/serving-registry";
|
|
14
16
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
15
17
|
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
16
18
|
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
|
@@ -55,6 +57,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
55
57
|
const hostFlavor = pathFlavorForPlatform(process.platform);
|
|
56
58
|
const sessionRules = new SessionRules();
|
|
57
59
|
const subagentRegistry = getSubagentSessionRegistry();
|
|
60
|
+
// Process-global, like subagentRegistry: an in-process child reads it from a
|
|
61
|
+
// separate jiti instance to learn whether its parent is draining its inbox.
|
|
62
|
+
const servingRegistry = getServingSessionRegistry();
|
|
58
63
|
// Single owner of subagent detection, shared across every consumer instead of
|
|
59
64
|
// threading the (subagentSessionsDir, platform, registry) triple into each.
|
|
60
65
|
const subagentDetection = new SubagentDetection({
|
|
@@ -111,6 +116,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
111
116
|
requestPermissionDecision,
|
|
112
117
|
forwardingDir: paths.forwardingDir,
|
|
113
118
|
registry: subagentRegistry,
|
|
119
|
+
servingRegistry,
|
|
120
|
+
getForwardingTimeoutMs: () =>
|
|
121
|
+
configStore.current().forwardingTimeoutMs ??
|
|
122
|
+
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
114
123
|
logger,
|
|
115
124
|
prompter,
|
|
116
125
|
// The published service is the narrow, session-scoped PermissionQuery a
|
|
@@ -160,7 +169,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
160
169
|
|
|
161
170
|
session = new PermissionSession(
|
|
162
171
|
paths,
|
|
163
|
-
new ForwardingManager(
|
|
172
|
+
new ForwardingManager({
|
|
173
|
+
detection: subagentDetection,
|
|
174
|
+
forwarder: requestServer,
|
|
175
|
+
serving: servingRegistry,
|
|
176
|
+
logger,
|
|
177
|
+
}),
|
|
164
178
|
permissionManager,
|
|
165
179
|
sessionRules,
|
|
166
180
|
configStore,
|
package/src/permission-gate.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface PermissionGateParams {
|
|
|
33
33
|
/** Message strings/factories for each outcome. */
|
|
34
34
|
messages: {
|
|
35
35
|
denyReason: string;
|
|
36
|
-
unavailableReason: string;
|
|
36
|
+
unavailableReason: (decision: PermissionPromptDecision) => string;
|
|
37
37
|
userDeniedReason: (decision: PermissionPromptDecision) => string;
|
|
38
38
|
};
|
|
39
39
|
}
|
|
@@ -65,7 +65,7 @@ export async function applyPermissionGate(
|
|
|
65
65
|
return {
|
|
66
66
|
action: "block",
|
|
67
67
|
reason: decision.confirmationUnavailable
|
|
68
|
-
? messages.unavailableReason
|
|
68
|
+
? messages.unavailableReason(decision)
|
|
69
69
|
: messages.userDeniedReason(decision),
|
|
70
70
|
};
|
|
71
71
|
}
|