@gotgenes/pi-permission-system 20.6.0 → 20.7.1
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 +17 -0
- package/README.md +2 -1
- package/config/config.example.json +1 -0
- package/docs/configuration.md +20 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +6 -0
- package/src/authority/authorizer.ts +10 -5
- package/src/authority/local-user-authorizer.ts +20 -7
- package/src/authority/permission-prompt-component.ts +300 -0
- package/src/authority/permission-prompt-decision.ts +258 -0
- package/src/config-loader.ts +9 -1
- package/src/config-modal.ts +15 -0
- package/src/config-schema.ts +7 -0
- package/src/extension-config.ts +4 -0
- package/src/index.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
## [20.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.7.0...pi-permission-system-v20.7.1) (2026-07-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** clip inline permission prompt lines to terminal width ([e3c6907](https://github.com/gotgenes/pi-packages/commit/e3c6907057070cbb444f4ca0dbc69661849b7dc2)), closes [#573](https://github.com/gotgenes/pi-packages/issues/573)
|
|
14
|
+
|
|
15
|
+
## [20.7.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.6.0...pi-permission-system-v20.7.0) (2026-07-14)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** add doublePressToConfirm config toggle ([bd401bf](https://github.com/gotgenes/pi-packages/commit/bd401bff0bd926313acfafe5e50b579c88e4002f))
|
|
21
|
+
* **pi-permission-system:** add inline permission prompt decision model ([08d6ec6](https://github.com/gotgenes/pi-packages/commit/08d6ec6b7e9abbcbf05b49579d307e3f5934cb3a))
|
|
22
|
+
* **pi-permission-system:** dispatch TUI permission prompts to the inline keybind dialog ([197c0f9](https://github.com/gotgenes/pi-packages/commit/197c0f91178f7010e84ed80eb3682dc27c1f737f))
|
|
23
|
+
* **pi-permission-system:** render inline keybind permission prompt ([7ac7dad](https://github.com/gotgenes/pi-packages/commit/7ac7dad3ef49f03e19b279056ea54d4c5a456c80))
|
|
24
|
+
|
|
8
25
|
## [20.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.5.0...pi-permission-system-v20.6.0) (2026-07-13)
|
|
9
26
|
|
|
10
27
|
|
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ All permissions use one of three states:
|
|
|
65
65
|
| `ask` | Prompts the user for confirmation via UI |
|
|
66
66
|
|
|
67
67
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
68
|
-
|
|
68
|
+
In an interactive TUI session the prompt is an inline keybind dialog — `y` approve, `s` approve for this session, `n` deny, `r` deny with a reason — where each hotkey arms and a second press confirms (configurable via `doublePressToConfirm`).
|
|
69
|
+
See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui) for the hotkeys and [docs/session-approvals.md](docs/session-approvals.md) for session-scoped rules and pattern suggestions.
|
|
69
70
|
|
|
70
71
|
The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
|
|
71
72
|
Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
|
package/docs/configuration.md
CHANGED
|
@@ -30,7 +30,7 @@ Project config overrides global config; per-agent frontmatter overrides both.
|
|
|
30
30
|
4. Project agent frontmatter
|
|
31
31
|
|
|
32
32
|
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
|
33
|
-
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replacement.
|
|
33
|
+
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`) use simple replacement.
|
|
34
34
|
|
|
35
35
|
## Full Example
|
|
36
36
|
|
|
@@ -42,6 +42,7 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
|
|
|
42
42
|
"debugLog": false,
|
|
43
43
|
"permissionReviewLog": true,
|
|
44
44
|
"yoloMode": false,
|
|
45
|
+
"doublePressToConfirm": true,
|
|
45
46
|
"toolInputPreviewMaxLength": 400,
|
|
46
47
|
"toolTextSummaryMaxLength": 120,
|
|
47
48
|
"piInfrastructureReadPaths": [],
|
|
@@ -85,6 +86,7 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
|
|
|
85
86
|
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
86
87
|
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
|
|
87
88
|
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
89
|
+
| `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. |
|
|
88
90
|
| `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. |
|
|
89
91
|
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
|
|
90
92
|
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
|
|
@@ -92,6 +94,23 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
|
|
|
92
94
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
93
95
|
No debug output is printed to the terminal.
|
|
94
96
|
|
|
97
|
+
### Inline permission dialog (TUI)
|
|
98
|
+
|
|
99
|
+
In an interactive **TUI** session, an `ask` decision opens an inline keybind dialog with one-key shortcuts:
|
|
100
|
+
|
|
101
|
+
| Key | Action |
|
|
102
|
+
| --- | ----------------------------------------------------------------- |
|
|
103
|
+
| `y` | Approve once |
|
|
104
|
+
| `s` | Approve for this session |
|
|
105
|
+
| `n` | Deny |
|
|
106
|
+
| `r` | Deny with a reason (opens an inline editor; a reason is required) |
|
|
107
|
+
|
|
108
|
+
Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option, and `esc` denies.
|
|
109
|
+
With `doublePressToConfirm` enabled (the default), a letter hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
|
|
110
|
+
Set `doublePressToConfirm` to `false` to commit on the first press.
|
|
111
|
+
|
|
112
|
+
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
|
|
113
|
+
|
|
95
114
|
### `piInfrastructureReadPaths` patterns
|
|
96
115
|
|
|
97
116
|
Each entry is either a plain directory prefix or a wildcard pattern.
|
package/package.json
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
"default": false,
|
|
26
26
|
"type": "boolean"
|
|
27
27
|
},
|
|
28
|
+
"doublePressToConfirm": {
|
|
29
|
+
"description": "Require a confirming second press of a decision hotkey in the inline permission dialog. Applies to TUI sessions only.",
|
|
30
|
+
"markdownDescription": "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.",
|
|
31
|
+
"default": true,
|
|
32
|
+
"type": "boolean"
|
|
33
|
+
},
|
|
28
34
|
"toolInputPreviewMaxLength": {
|
|
29
35
|
"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.",
|
|
30
36
|
"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,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "#src/authority/permission-
|
|
4
|
+
PromptPreferences,
|
|
5
|
+
requestPermissionDecision,
|
|
6
|
+
} from "#src/authority/permission-prompt-component";
|
|
6
7
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
7
8
|
import type { PermissionEventBus } from "#src/permission-events";
|
|
8
9
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
@@ -33,8 +34,10 @@ export interface AuthorizerSelectionDeps {
|
|
|
33
34
|
detection: SubagentDetector;
|
|
34
35
|
/** Event bus used by `LocalUserAuthorizer` for the `permissions:ui_prompt` broadcast. */
|
|
35
36
|
events: PermissionEventBus;
|
|
37
|
+
/** Read live at prompt time; threaded into `LocalUserAuthorizer`. */
|
|
38
|
+
getPromptPreferences: () => PromptPreferences;
|
|
36
39
|
/** Injected for testability; production callers pass the real function. */
|
|
37
|
-
|
|
40
|
+
requestPermissionDecision: typeof requestPermissionDecision;
|
|
38
41
|
/** Forwarding directory `ParentAuthorizer` reads/writes request and response files under. */
|
|
39
42
|
forwardingDir: string;
|
|
40
43
|
/** In-process subagent session registry for forwarding target resolution. */
|
|
@@ -57,8 +60,10 @@ export function selectAuthorizer(
|
|
|
57
60
|
if (ctx.hasUI) {
|
|
58
61
|
return new LocalUserAuthorizer({
|
|
59
62
|
ui: ctx.ui,
|
|
63
|
+
mode: ctx.mode,
|
|
60
64
|
events: deps.events,
|
|
61
|
-
|
|
65
|
+
getPromptPreferences: deps.getPromptPreferences,
|
|
66
|
+
requestPermissionDecision: deps.requestPermissionDecision,
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
if (deps.detection.isSubagent(ctx)) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
2
|
import type {
|
|
2
|
-
PermissionDecisionUi,
|
|
3
3
|
PermissionPromptDecision,
|
|
4
4
|
RequestPermissionOptions,
|
|
5
|
-
requestPermissionDecisionFromUi,
|
|
6
5
|
} from "#src/authority/permission-dialog";
|
|
6
|
+
import type {
|
|
7
|
+
PermissionPromptUi,
|
|
8
|
+
PromptPreferences,
|
|
9
|
+
requestPermissionDecision,
|
|
10
|
+
} from "#src/authority/permission-prompt-component";
|
|
7
11
|
import { buildForwardedScopeLabels } from "#src/pattern-suggest";
|
|
8
12
|
import {
|
|
9
13
|
emitUiPromptEvent,
|
|
@@ -15,12 +19,16 @@ import type { PromptPermissionDetails } from "./permission-prompter";
|
|
|
15
19
|
|
|
16
20
|
/** Dependencies required by {@link LocalUserAuthorizer}. */
|
|
17
21
|
export interface LocalUserAuthorizerDeps {
|
|
18
|
-
/** The active session's UI surface. */
|
|
19
|
-
ui:
|
|
22
|
+
/** The active session's UI surface (select/input plus the inline `custom` dialog). */
|
|
23
|
+
ui: PermissionPromptUi;
|
|
24
|
+
/** The session run mode; the dispatcher renders the inline dialog only in `"tui"`. */
|
|
25
|
+
mode: ExtensionContext["mode"];
|
|
20
26
|
/** Event bus used for the `permissions:ui_prompt` broadcast. */
|
|
21
27
|
events: PermissionEventBus;
|
|
28
|
+
/** Read live at prompt time so a settings-modal toggle takes effect on the next prompt. */
|
|
29
|
+
getPromptPreferences: () => PromptPreferences;
|
|
22
30
|
/** Injected for testability; production callers pass the real function. */
|
|
23
|
-
|
|
31
|
+
requestPermissionDecision: typeof requestPermissionDecision;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/**
|
|
@@ -41,8 +49,13 @@ export class LocalUserAuthorizer implements Authorizer {
|
|
|
41
49
|
): Promise<PermissionPromptDecision> {
|
|
42
50
|
const uiPrompt = buildUiPrompt(details);
|
|
43
51
|
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
44
|
-
return this.deps.
|
|
45
|
-
|
|
52
|
+
return this.deps.requestPermissionDecision(
|
|
53
|
+
{
|
|
54
|
+
mode: this.deps.mode,
|
|
55
|
+
ui: this.deps.ui,
|
|
56
|
+
doublePressToConfirm:
|
|
57
|
+
this.deps.getPromptPreferences().doublePressToConfirm,
|
|
58
|
+
},
|
|
46
59
|
details.forwarding
|
|
47
60
|
? "Permission Required (Subagent)"
|
|
48
61
|
: "Permission Required",
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ExtensionUIContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
type Component,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import {
|
|
12
|
+
type PermissionPromptDecision,
|
|
13
|
+
type RequestPermissionOptions,
|
|
14
|
+
requestPermissionDecisionFromUi,
|
|
15
|
+
} from "#src/authority/permission-dialog";
|
|
16
|
+
import {
|
|
17
|
+
initialPromptState,
|
|
18
|
+
type PromptEvent,
|
|
19
|
+
type PromptKey,
|
|
20
|
+
type PromptModelConfig,
|
|
21
|
+
type PromptViewState,
|
|
22
|
+
reducePrompt,
|
|
23
|
+
} from "#src/authority/permission-prompt-decision";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inline `ctx.ui.custom` permission dialog for TUI sessions.
|
|
27
|
+
*
|
|
28
|
+
* All interaction logic lives in the pure {@link reducePrompt} model; this
|
|
29
|
+
* module is the thin adapter that renders the model's state to lines, maps raw
|
|
30
|
+
* keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
|
|
31
|
+
* with the committed {@link PermissionPromptDecision}. The component renders
|
|
32
|
+
* inline (never as an overlay).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The subset of the session UI surface the inline dialog needs. */
|
|
36
|
+
export type PermissionPromptUi = Pick<
|
|
37
|
+
ExtensionUIContext,
|
|
38
|
+
"select" | "input" | "custom"
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/** The resolved presentation context selected once per activation. */
|
|
42
|
+
export interface PermissionPromptView {
|
|
43
|
+
mode: ExtensionContext["mode"];
|
|
44
|
+
ui: PermissionPromptUi;
|
|
45
|
+
doublePressToConfirm: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
|
|
49
|
+
export interface PromptPreferences {
|
|
50
|
+
doublePressToConfirm: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Route a permission ask to the inline keybind dialog in TUI mode, or the
|
|
55
|
+
* `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
|
|
56
|
+
*
|
|
57
|
+
* The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
|
|
58
|
+
* one place so the fallback and the inline component never both render.
|
|
59
|
+
*/
|
|
60
|
+
export function requestPermissionDecision(
|
|
61
|
+
view: PermissionPromptView,
|
|
62
|
+
title: string,
|
|
63
|
+
message: string,
|
|
64
|
+
options?: RequestPermissionOptions,
|
|
65
|
+
): Promise<PermissionPromptDecision> {
|
|
66
|
+
if (view.mode === "tui") {
|
|
67
|
+
return presentInlinePermissionPrompt(view, title, message, options);
|
|
68
|
+
}
|
|
69
|
+
return requestPermissionDecisionFromUi(view.ui, title, message, options);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
|
|
73
|
+
interface PromptTheme {
|
|
74
|
+
fg(color: string, text: string): string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
|
78
|
+
|
|
79
|
+
const OPTION_LABELS: Record<PromptKey, string> = {
|
|
80
|
+
y: "Yes",
|
|
81
|
+
s: DEFAULT_SESSION_LABEL,
|
|
82
|
+
n: "No",
|
|
83
|
+
r: "No, provide reason",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
87
|
+
|
|
88
|
+
export function presentInlinePermissionPrompt(
|
|
89
|
+
view: PermissionPromptView,
|
|
90
|
+
title: string,
|
|
91
|
+
message: string,
|
|
92
|
+
options?: RequestPermissionOptions,
|
|
93
|
+
): Promise<PermissionPromptDecision> {
|
|
94
|
+
const config: PromptModelConfig = {
|
|
95
|
+
doublePressToConfirm: view.doublePressToConfirm,
|
|
96
|
+
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
97
|
+
sessionScope: options?.sessionScope,
|
|
98
|
+
};
|
|
99
|
+
return view.ui.custom<PermissionPromptDecision>(
|
|
100
|
+
(tui, theme, _keybindings, done) =>
|
|
101
|
+
new PermissionPromptComponent(
|
|
102
|
+
theme,
|
|
103
|
+
config,
|
|
104
|
+
title,
|
|
105
|
+
message,
|
|
106
|
+
() => {
|
|
107
|
+
tui.requestRender();
|
|
108
|
+
},
|
|
109
|
+
done,
|
|
110
|
+
),
|
|
111
|
+
{ overlay: false },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class PermissionPromptComponent implements Component {
|
|
116
|
+
private state: PromptViewState;
|
|
117
|
+
private reasonBuffer = "";
|
|
118
|
+
|
|
119
|
+
constructor(
|
|
120
|
+
private readonly theme: PromptTheme,
|
|
121
|
+
private readonly config: PromptModelConfig,
|
|
122
|
+
private readonly title: string,
|
|
123
|
+
private readonly message: string,
|
|
124
|
+
private readonly requestRender: () => void,
|
|
125
|
+
private readonly done: (decision: PermissionPromptDecision) => void,
|
|
126
|
+
) {
|
|
127
|
+
this.state = initialPromptState(config);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
invalidate(): void {
|
|
131
|
+
// No cached rendering state to clear.
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
render(width: number): string[] {
|
|
135
|
+
return fitToWidth(this.renderStep(), width);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private renderStep(): string[] {
|
|
139
|
+
switch (this.state.step) {
|
|
140
|
+
case "decision":
|
|
141
|
+
return this.renderDecision();
|
|
142
|
+
case "reason":
|
|
143
|
+
return this.renderReason();
|
|
144
|
+
case "scope":
|
|
145
|
+
return this.renderScope();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
handleInput(data: string): void {
|
|
150
|
+
if (this.state.step === "reason") {
|
|
151
|
+
this.handleReasonInput(data);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const event = this.toEvent(data);
|
|
155
|
+
if (event) {
|
|
156
|
+
this.apply(event);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private handleReasonInput(data: string): void {
|
|
161
|
+
if (matchesKey(data, "enter")) {
|
|
162
|
+
this.apply({ type: "submitReason", draft: this.reasonBuffer });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (matchesKey(data, "escape")) {
|
|
166
|
+
this.reasonBuffer = "";
|
|
167
|
+
this.apply({ type: "cancel" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (matchesKey(data, "backspace")) {
|
|
171
|
+
this.reasonBuffer = this.reasonBuffer.slice(0, -1);
|
|
172
|
+
this.requestRender();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (isPrintable(data)) {
|
|
176
|
+
this.reasonBuffer += data;
|
|
177
|
+
this.requestRender();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private toEvent(data: string): PromptEvent | undefined {
|
|
182
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
183
|
+
return { type: "nav", direction: "up" };
|
|
184
|
+
}
|
|
185
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
186
|
+
return { type: "nav", direction: "down" };
|
|
187
|
+
}
|
|
188
|
+
if (matchesKey(data, "enter")) {
|
|
189
|
+
return { type: "confirm" };
|
|
190
|
+
}
|
|
191
|
+
if (matchesKey(data, "escape")) {
|
|
192
|
+
return { type: "cancel" };
|
|
193
|
+
}
|
|
194
|
+
if (this.state.step === "decision") {
|
|
195
|
+
const key = OPTION_ORDER.find((option) => matchesKey(data, option));
|
|
196
|
+
if (key) {
|
|
197
|
+
return { type: "hotkey", key };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private apply(event: PromptEvent): void {
|
|
204
|
+
const outcome = reducePrompt(this.config, this.state, event);
|
|
205
|
+
if (outcome.kind === "decision") {
|
|
206
|
+
this.done(outcome.decision);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (outcome.state.step === "reason" && this.state.step !== "reason") {
|
|
210
|
+
this.reasonBuffer = "";
|
|
211
|
+
}
|
|
212
|
+
this.state = outcome.state;
|
|
213
|
+
this.requestRender();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private renderDecision(): string[] {
|
|
217
|
+
const lines = [this.theme.fg("accent", this.title), this.message, ""];
|
|
218
|
+
for (const key of OPTION_ORDER) {
|
|
219
|
+
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
|
|
220
|
+
const selected = this.state.highlightedKey === key;
|
|
221
|
+
const marker = selected ? "▶" : " ";
|
|
222
|
+
const row = `${marker} (${key}) ${label}`;
|
|
223
|
+
lines.push(selected ? this.theme.fg("accent", row) : row);
|
|
224
|
+
}
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push(
|
|
227
|
+
this.state.hint ||
|
|
228
|
+
this.theme.fg(
|
|
229
|
+
"muted",
|
|
230
|
+
"↑/↓ move · enter confirm · esc deny · press a letter, then again to confirm",
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private renderReason(): string[] {
|
|
237
|
+
const lines = [
|
|
238
|
+
this.theme.fg("accent", this.title),
|
|
239
|
+
this.message,
|
|
240
|
+
"",
|
|
241
|
+
`Reason (required): ${this.reasonBuffer}\u2588`,
|
|
242
|
+
];
|
|
243
|
+
if (this.state.reasonError) {
|
|
244
|
+
lines.push(this.theme.fg("error", this.state.reasonError));
|
|
245
|
+
}
|
|
246
|
+
lines.push("");
|
|
247
|
+
lines.push(this.theme.fg("muted", "enter submit · esc back"));
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private renderScope(): string[] {
|
|
252
|
+
const scope = this.config.sessionScope;
|
|
253
|
+
const subagentLabel = scope?.subagentLabel ?? "This subagent only";
|
|
254
|
+
const servingLabel = scope?.servingSessionLabel ?? "The whole session";
|
|
255
|
+
const rows: Array<{ label: string; serving: boolean }> = [
|
|
256
|
+
{ label: subagentLabel, serving: false },
|
|
257
|
+
{ label: servingLabel, serving: true },
|
|
258
|
+
];
|
|
259
|
+
const lines = [
|
|
260
|
+
this.theme.fg("accent", this.title),
|
|
261
|
+
"Apply this session grant to:",
|
|
262
|
+
"",
|
|
263
|
+
];
|
|
264
|
+
for (const row of rows) {
|
|
265
|
+
const selected = this.state.scopeServing === row.serving;
|
|
266
|
+
const marker = selected ? "▶" : " ";
|
|
267
|
+
const text = `${marker} ${row.label}`;
|
|
268
|
+
lines.push(selected ? this.theme.fg("accent", text) : text);
|
|
269
|
+
}
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Fit rendered lines to the terminal width, satisfying the `ctx.ui.custom`
|
|
278
|
+
* contract that every returned line be a single visual row no wider than
|
|
279
|
+
* `width`. Long lines (e.g. a wide tool-preview message) are wrapped rather
|
|
280
|
+
* than clipped so no content is lost; the final `truncateToWidth` guards the
|
|
281
|
+
* edge cases `wrapTextWithAnsi` cannot split (a lone wide grapheme).
|
|
282
|
+
*/
|
|
283
|
+
function fitToWidth(lines: string[], width: number): string[] {
|
|
284
|
+
if (width <= 0) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
return lines.flatMap((line) =>
|
|
288
|
+
wrapTextWithAnsi(line, width).map((wrapped) =>
|
|
289
|
+
truncateToWidth(wrapped, width),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isPrintable(data: string): boolean {
|
|
295
|
+
if (data.length !== 1) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const code = data.charCodeAt(0);
|
|
299
|
+
return code >= 0x20 && code !== 0x7f;
|
|
300
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDeniedPermissionDecision,
|
|
3
|
+
normalizePermissionDenialReason,
|
|
4
|
+
type PermissionPromptDecision,
|
|
5
|
+
type RequestPermissionOptions,
|
|
6
|
+
} from "#src/authority/permission-dialog";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Pure decision model for the inline keybind permission dialog.
|
|
10
|
+
*
|
|
11
|
+
* The interaction logic — which hotkey produces which decision, double-press
|
|
12
|
+
* arming, step transitions, and reason validation — lives here with no SDK or
|
|
13
|
+
* TUI imports, so it is unit-testable directly. The `ctx.ui.custom` component
|
|
14
|
+
* ({@link file://./permission-prompt-component.ts}) is a thin adapter that
|
|
15
|
+
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The four decision hotkeys, in display order. */
|
|
19
|
+
export type PromptKey = "y" | "s" | "n" | "r";
|
|
20
|
+
|
|
21
|
+
/** Which sub-view the dialog is showing. */
|
|
22
|
+
export type PromptStep = "decision" | "reason" | "scope";
|
|
23
|
+
|
|
24
|
+
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
25
|
+
|
|
26
|
+
const OPTION_VERBS: Record<PromptKey, string> = {
|
|
27
|
+
y: "approve",
|
|
28
|
+
s: "approve for this session",
|
|
29
|
+
n: "deny",
|
|
30
|
+
r: "deny with a reason",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Static configuration for a single prompt presentation. */
|
|
34
|
+
export interface PromptModelConfig {
|
|
35
|
+
/** When true, a letter hotkey arms first and commits only on a second press. */
|
|
36
|
+
doublePressToConfirm: boolean;
|
|
37
|
+
/** Label shown beside the approve-for-session option. */
|
|
38
|
+
sessionLabel: string;
|
|
39
|
+
/**
|
|
40
|
+
* Forwarded asks only: when set, confirming `s` opens a second step choosing
|
|
41
|
+
* whether the grant applies to the requesting subagent only (least-privilege
|
|
42
|
+
* default) or the whole serving session.
|
|
43
|
+
*/
|
|
44
|
+
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The re-render view state the component draws from. */
|
|
48
|
+
export interface PromptViewState {
|
|
49
|
+
step: PromptStep;
|
|
50
|
+
highlightedKey: PromptKey;
|
|
51
|
+
/** Set only while awaiting the confirming second press of a hotkey. */
|
|
52
|
+
armedKey?: PromptKey;
|
|
53
|
+
/** "Press y again to approve." while armed; empty otherwise. */
|
|
54
|
+
hint: string;
|
|
55
|
+
reasonDraft: string;
|
|
56
|
+
/** Set when an empty reason submit is rejected. */
|
|
57
|
+
reasonError?: string;
|
|
58
|
+
/** Scope step: false = subagent-only (default), true = whole serving session. */
|
|
59
|
+
scopeServing: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An input event the reducer understands. */
|
|
63
|
+
export type PromptEvent =
|
|
64
|
+
| { type: "nav"; direction: "up" | "down" }
|
|
65
|
+
| { type: "hotkey"; key: PromptKey }
|
|
66
|
+
| { type: "confirm" }
|
|
67
|
+
| { type: "cancel" }
|
|
68
|
+
| { type: "submitReason"; draft: string };
|
|
69
|
+
|
|
70
|
+
/** Either a re-render or a terminal decision. */
|
|
71
|
+
export type PromptOutcome =
|
|
72
|
+
| { kind: "render"; state: PromptViewState }
|
|
73
|
+
| { kind: "decision"; decision: PermissionPromptDecision };
|
|
74
|
+
|
|
75
|
+
export function initialPromptState(
|
|
76
|
+
_config: PromptModelConfig,
|
|
77
|
+
): PromptViewState {
|
|
78
|
+
return {
|
|
79
|
+
step: "decision",
|
|
80
|
+
highlightedKey: "y",
|
|
81
|
+
armedKey: undefined,
|
|
82
|
+
hint: "",
|
|
83
|
+
reasonDraft: "",
|
|
84
|
+
reasonError: undefined,
|
|
85
|
+
scopeServing: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Advance the dialog by one input event, returning either the next view state
|
|
91
|
+
* to render or the committed {@link PermissionPromptDecision}.
|
|
92
|
+
*/
|
|
93
|
+
export function reducePrompt(
|
|
94
|
+
config: PromptModelConfig,
|
|
95
|
+
state: PromptViewState,
|
|
96
|
+
event: PromptEvent,
|
|
97
|
+
): PromptOutcome {
|
|
98
|
+
switch (state.step) {
|
|
99
|
+
case "decision":
|
|
100
|
+
return reduceDecisionStep(config, state, event);
|
|
101
|
+
case "reason":
|
|
102
|
+
return reduceReasonStep(state, event);
|
|
103
|
+
case "scope":
|
|
104
|
+
return reduceScopeStep(state, event);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reduceDecisionStep(
|
|
109
|
+
config: PromptModelConfig,
|
|
110
|
+
state: PromptViewState,
|
|
111
|
+
event: PromptEvent,
|
|
112
|
+
): PromptOutcome {
|
|
113
|
+
switch (event.type) {
|
|
114
|
+
case "nav":
|
|
115
|
+
return render({
|
|
116
|
+
...state,
|
|
117
|
+
highlightedKey: shiftKey(state.highlightedKey, event.direction),
|
|
118
|
+
armedKey: undefined,
|
|
119
|
+
hint: "",
|
|
120
|
+
});
|
|
121
|
+
case "hotkey":
|
|
122
|
+
return pressHotkey(config, state, event.key);
|
|
123
|
+
case "confirm":
|
|
124
|
+
return commit(config, state, state.highlightedKey);
|
|
125
|
+
case "cancel":
|
|
126
|
+
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
|
127
|
+
case "submitReason":
|
|
128
|
+
return render(state);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pressHotkey(
|
|
133
|
+
config: PromptModelConfig,
|
|
134
|
+
state: PromptViewState,
|
|
135
|
+
key: PromptKey,
|
|
136
|
+
): PromptOutcome {
|
|
137
|
+
if (!config.doublePressToConfirm || state.armedKey === key) {
|
|
138
|
+
return commit(config, state, key);
|
|
139
|
+
}
|
|
140
|
+
return render({
|
|
141
|
+
...state,
|
|
142
|
+
highlightedKey: key,
|
|
143
|
+
armedKey: key,
|
|
144
|
+
hint: `Press ${key} again to ${OPTION_VERBS[key]}.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function commit(
|
|
149
|
+
config: PromptModelConfig,
|
|
150
|
+
state: PromptViewState,
|
|
151
|
+
key: PromptKey,
|
|
152
|
+
): PromptOutcome {
|
|
153
|
+
switch (key) {
|
|
154
|
+
case "y":
|
|
155
|
+
return {
|
|
156
|
+
kind: "decision",
|
|
157
|
+
decision: { approved: true, state: "approved" },
|
|
158
|
+
};
|
|
159
|
+
case "n":
|
|
160
|
+
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
|
161
|
+
case "r":
|
|
162
|
+
return render({
|
|
163
|
+
...state,
|
|
164
|
+
step: "reason",
|
|
165
|
+
highlightedKey: "r",
|
|
166
|
+
armedKey: undefined,
|
|
167
|
+
hint: "",
|
|
168
|
+
reasonDraft: "",
|
|
169
|
+
reasonError: undefined,
|
|
170
|
+
});
|
|
171
|
+
case "s":
|
|
172
|
+
if (config.sessionScope) {
|
|
173
|
+
return render({
|
|
174
|
+
...state,
|
|
175
|
+
step: "scope",
|
|
176
|
+
highlightedKey: "s",
|
|
177
|
+
armedKey: undefined,
|
|
178
|
+
hint: "",
|
|
179
|
+
scopeServing: false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
kind: "decision",
|
|
184
|
+
decision: { approved: true, state: "approved_for_session" },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function reduceReasonStep(
|
|
190
|
+
state: PromptViewState,
|
|
191
|
+
event: PromptEvent,
|
|
192
|
+
): PromptOutcome {
|
|
193
|
+
if (event.type === "cancel") {
|
|
194
|
+
return render({
|
|
195
|
+
...state,
|
|
196
|
+
step: "decision",
|
|
197
|
+
armedKey: undefined,
|
|
198
|
+
hint: "",
|
|
199
|
+
reasonDraft: "",
|
|
200
|
+
reasonError: undefined,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (event.type === "submitReason") {
|
|
204
|
+
const reason = normalizePermissionDenialReason(event.draft);
|
|
205
|
+
if (reason === undefined) {
|
|
206
|
+
return render({
|
|
207
|
+
...state,
|
|
208
|
+
reasonDraft: event.draft,
|
|
209
|
+
reasonError: "A reason is required.",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
kind: "decision",
|
|
214
|
+
decision: createDeniedPermissionDecision(reason),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return render(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function reduceScopeStep(
|
|
221
|
+
state: PromptViewState,
|
|
222
|
+
event: PromptEvent,
|
|
223
|
+
): PromptOutcome {
|
|
224
|
+
switch (event.type) {
|
|
225
|
+
case "nav":
|
|
226
|
+
return render({ ...state, scopeServing: event.direction === "down" });
|
|
227
|
+
case "confirm":
|
|
228
|
+
return {
|
|
229
|
+
kind: "decision",
|
|
230
|
+
decision: {
|
|
231
|
+
approved: true,
|
|
232
|
+
state: state.scopeServing
|
|
233
|
+
? "approved_for_serving_session"
|
|
234
|
+
: "approved_for_session",
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
case "cancel":
|
|
238
|
+
return render({
|
|
239
|
+
...state,
|
|
240
|
+
step: "decision",
|
|
241
|
+
armedKey: undefined,
|
|
242
|
+
hint: "",
|
|
243
|
+
});
|
|
244
|
+
default:
|
|
245
|
+
return render(state);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
|
|
250
|
+
const index = OPTION_ORDER.indexOf(current);
|
|
251
|
+
const delta = direction === "down" ? 1 : -1;
|
|
252
|
+
const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
|
|
253
|
+
return OPTION_ORDER[next] ?? current;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function render(state: PromptViewState): PromptOutcome {
|
|
257
|
+
return { kind: "render", state };
|
|
258
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -197,6 +197,9 @@ function formatConfigIssues(error: ZodError): string[] {
|
|
|
197
197
|
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
|
198
198
|
* the override (override-wins, same as scalars).
|
|
199
199
|
*/
|
|
200
|
+
// Scalar knobs merged by override-replaces-base; keep in sync with
|
|
201
|
+
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
|
|
202
|
+
// yoloMode, doublePressToConfirm).
|
|
200
203
|
export function mergeUnifiedConfigs(
|
|
201
204
|
base: UnifiedPermissionConfig,
|
|
202
205
|
override: UnifiedPermissionConfig,
|
|
@@ -204,7 +207,12 @@ export function mergeUnifiedConfigs(
|
|
|
204
207
|
const merged: UnifiedPermissionConfig = {};
|
|
205
208
|
|
|
206
209
|
// Boolean scalars: override replaces base when defined
|
|
207
|
-
for (const key of [
|
|
210
|
+
for (const key of [
|
|
211
|
+
"debugLog",
|
|
212
|
+
"permissionReviewLog",
|
|
213
|
+
"yoloMode",
|
|
214
|
+
"doublePressToConfirm",
|
|
215
|
+
] as const) {
|
|
208
216
|
const value = override[key] ?? base[key];
|
|
209
217
|
if (value !== undefined) {
|
|
210
218
|
merged[key] = value;
|
package/src/config-modal.ts
CHANGED
|
@@ -51,6 +51,7 @@ function cloneDefaultConfig(): PermissionSystemExtensionConfig {
|
|
|
51
51
|
debugLog: DEFAULT_EXTENSION_CONFIG.debugLog,
|
|
52
52
|
permissionReviewLog: DEFAULT_EXTENSION_CONFIG.permissionReviewLog,
|
|
53
53
|
yoloMode: DEFAULT_EXTENSION_CONFIG.yoloMode,
|
|
54
|
+
doublePressToConfirm: DEFAULT_EXTENSION_CONFIG.doublePressToConfirm,
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -113,6 +114,14 @@ function buildSettingItems(
|
|
|
113
114
|
currentValue: toOnOff(config.debugLog),
|
|
114
115
|
values: ON_OFF,
|
|
115
116
|
},
|
|
117
|
+
{
|
|
118
|
+
id: "doublePressToConfirm",
|
|
119
|
+
label: "Double-press to confirm",
|
|
120
|
+
description:
|
|
121
|
+
"Require a confirming second press of a decision hotkey in the inline TUI permission dialog",
|
|
122
|
+
currentValue: toOnOff(config.doublePressToConfirm),
|
|
123
|
+
values: ON_OFF,
|
|
124
|
+
},
|
|
116
125
|
];
|
|
117
126
|
}
|
|
118
127
|
|
|
@@ -128,6 +137,8 @@ function applySetting(
|
|
|
128
137
|
return { ...config, permissionReviewLog: value === "on" };
|
|
129
138
|
case "debugLog":
|
|
130
139
|
return { ...config, debugLog: value === "on" };
|
|
140
|
+
case "doublePressToConfirm":
|
|
141
|
+
return { ...config, doublePressToConfirm: value === "on" };
|
|
131
142
|
default:
|
|
132
143
|
return config;
|
|
133
144
|
}
|
|
@@ -143,6 +154,10 @@ function syncSettingValues(
|
|
|
143
154
|
toOnOff(config.permissionReviewLog),
|
|
144
155
|
);
|
|
145
156
|
settingsList.updateValue("debugLog", toOnOff(config.debugLog));
|
|
157
|
+
settingsList.updateValue(
|
|
158
|
+
"doublePressToConfirm",
|
|
159
|
+
toOnOff(config.doublePressToConfirm),
|
|
160
|
+
);
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
function getArgumentCompletions(
|
package/src/config-schema.ts
CHANGED
|
@@ -180,6 +180,13 @@ export const unifiedConfigSchema = z
|
|
|
180
180
|
"Auto-approve `ask`-state permission checks, including subagent approval forwarding.\n\n⚠️ **Use with caution** — this disables all interactive confirmation prompts.",
|
|
181
181
|
default: false,
|
|
182
182
|
}),
|
|
183
|
+
doublePressToConfirm: z.boolean().optional().meta({
|
|
184
|
+
description:
|
|
185
|
+
"Require a confirming second press of a decision hotkey in the inline permission dialog. Applies to TUI sessions only.",
|
|
186
|
+
markdownDescription:
|
|
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
|
+
default: true,
|
|
189
|
+
}),
|
|
183
190
|
toolInputPreviewMaxLength: z.number().int().min(1).optional().meta({
|
|
184
191
|
description:
|
|
185
192
|
"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/extension-config.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
13
13
|
debugLog: boolean;
|
|
14
14
|
permissionReviewLog: boolean;
|
|
15
15
|
yoloMode: boolean;
|
|
16
|
+
/** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */
|
|
17
|
+
doublePressToConfirm: boolean;
|
|
16
18
|
/** Additional directories to auto-allow for reads as Pi infrastructure. */
|
|
17
19
|
piInfrastructureReadPaths?: string[];
|
|
18
20
|
/** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
|
|
@@ -27,6 +29,7 @@ export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
|
|
27
29
|
debugLog: false,
|
|
28
30
|
permissionReviewLog: true,
|
|
29
31
|
yoloMode: false,
|
|
32
|
+
doublePressToConfirm: true,
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
function resolveExtensionRoot(moduleUrl = import.meta.url): string {
|
|
@@ -58,6 +61,7 @@ export function normalizePermissionSystemConfig(
|
|
|
58
61
|
debugLog: raw.debugLog === true,
|
|
59
62
|
permissionReviewLog: raw.permissionReviewLog !== false,
|
|
60
63
|
yoloMode: raw.yoloMode === true,
|
|
64
|
+
doublePressToConfirm: raw.doublePressToConfirm !== false,
|
|
61
65
|
};
|
|
62
66
|
if (raw.piInfrastructureReadPaths !== undefined) {
|
|
63
67
|
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ServingPolicy,
|
|
9
9
|
} from "./authority/forwarded-request-server";
|
|
10
10
|
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
11
|
-
import {
|
|
11
|
+
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
|
12
12
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
13
13
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
14
14
|
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
@@ -100,7 +100,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
100
100
|
const authorizerSelection = new AuthorizerSelection({
|
|
101
101
|
detection: subagentDetection,
|
|
102
102
|
events: pi.events,
|
|
103
|
-
|
|
103
|
+
getPromptPreferences: () => ({
|
|
104
|
+
doublePressToConfirm: configStore.current().doublePressToConfirm,
|
|
105
|
+
}),
|
|
106
|
+
requestPermissionDecision,
|
|
104
107
|
forwardingDir: paths.forwardingDir,
|
|
105
108
|
registry: subagentRegistry,
|
|
106
109
|
logger,
|