@gotgenes/pi-permission-system 20.5.0 → 20.7.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.
@@ -0,0 +1,273 @@
1
+ import type {
2
+ ExtensionContext,
3
+ ExtensionUIContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { type Component, matchesKey } from "@earendil-works/pi-tui";
6
+ import {
7
+ type PermissionPromptDecision,
8
+ type RequestPermissionOptions,
9
+ requestPermissionDecisionFromUi,
10
+ } from "#src/authority/permission-dialog";
11
+ import {
12
+ initialPromptState,
13
+ type PromptEvent,
14
+ type PromptKey,
15
+ type PromptModelConfig,
16
+ type PromptViewState,
17
+ reducePrompt,
18
+ } from "#src/authority/permission-prompt-decision";
19
+
20
+ /**
21
+ * Inline `ctx.ui.custom` permission dialog for TUI sessions.
22
+ *
23
+ * All interaction logic lives in the pure {@link reducePrompt} model; this
24
+ * module is the thin adapter that renders the model's state to lines, maps raw
25
+ * keystrokes to {@link PromptEvent}s, and resolves the `ctx.ui.custom` promise
26
+ * with the committed {@link PermissionPromptDecision}. The component renders
27
+ * inline (never as an overlay).
28
+ */
29
+
30
+ /** The subset of the session UI surface the inline dialog needs. */
31
+ export type PermissionPromptUi = Pick<
32
+ ExtensionUIContext,
33
+ "select" | "input" | "custom"
34
+ >;
35
+
36
+ /** The resolved presentation context selected once per activation. */
37
+ export interface PermissionPromptView {
38
+ mode: ExtensionContext["mode"];
39
+ ui: PermissionPromptUi;
40
+ doublePressToConfirm: boolean;
41
+ }
42
+
43
+ /** Live prompt-behavior preferences read at prompt time (see `doublePressToConfirm`). */
44
+ export interface PromptPreferences {
45
+ doublePressToConfirm: boolean;
46
+ }
47
+
48
+ /**
49
+ * Route a permission ask to the inline keybind dialog in TUI mode, or the
50
+ * `select()`/`input()` flow otherwise (RPC / frontend — the #519 constraint).
51
+ *
52
+ * The single entry the `LocalUserAuthorizer` calls; keeps the mode dispatch in
53
+ * one place so the fallback and the inline component never both render.
54
+ */
55
+ export function requestPermissionDecision(
56
+ view: PermissionPromptView,
57
+ title: string,
58
+ message: string,
59
+ options?: RequestPermissionOptions,
60
+ ): Promise<PermissionPromptDecision> {
61
+ if (view.mode === "tui") {
62
+ return presentInlinePermissionPrompt(view, title, message, options);
63
+ }
64
+ return requestPermissionDecisionFromUi(view.ui, title, message, options);
65
+ }
66
+
67
+ /** Minimal theme surface the dialog uses; satisfied by the real SDK theme. */
68
+ interface PromptTheme {
69
+ fg(color: string, text: string): string;
70
+ }
71
+
72
+ const DEFAULT_SESSION_LABEL = "Yes, for this session";
73
+
74
+ const OPTION_LABELS: Record<PromptKey, string> = {
75
+ y: "Yes",
76
+ s: DEFAULT_SESSION_LABEL,
77
+ n: "No",
78
+ r: "No, provide reason",
79
+ };
80
+
81
+ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
82
+
83
+ export function presentInlinePermissionPrompt(
84
+ view: PermissionPromptView,
85
+ title: string,
86
+ message: string,
87
+ options?: RequestPermissionOptions,
88
+ ): Promise<PermissionPromptDecision> {
89
+ const config: PromptModelConfig = {
90
+ doublePressToConfirm: view.doublePressToConfirm,
91
+ sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
92
+ sessionScope: options?.sessionScope,
93
+ };
94
+ return view.ui.custom<PermissionPromptDecision>(
95
+ (tui, theme, _keybindings, done) =>
96
+ new PermissionPromptComponent(
97
+ theme,
98
+ config,
99
+ title,
100
+ message,
101
+ () => {
102
+ tui.requestRender();
103
+ },
104
+ done,
105
+ ),
106
+ { overlay: false },
107
+ );
108
+ }
109
+
110
+ class PermissionPromptComponent implements Component {
111
+ private state: PromptViewState;
112
+ private reasonBuffer = "";
113
+
114
+ constructor(
115
+ private readonly theme: PromptTheme,
116
+ private readonly config: PromptModelConfig,
117
+ private readonly title: string,
118
+ private readonly message: string,
119
+ private readonly requestRender: () => void,
120
+ private readonly done: (decision: PermissionPromptDecision) => void,
121
+ ) {
122
+ this.state = initialPromptState(config);
123
+ }
124
+
125
+ invalidate(): void {
126
+ // No cached rendering state to clear.
127
+ }
128
+
129
+ render(_width: number): string[] {
130
+ switch (this.state.step) {
131
+ case "decision":
132
+ return this.renderDecision();
133
+ case "reason":
134
+ return this.renderReason();
135
+ case "scope":
136
+ return this.renderScope();
137
+ }
138
+ }
139
+
140
+ handleInput(data: string): void {
141
+ if (this.state.step === "reason") {
142
+ this.handleReasonInput(data);
143
+ return;
144
+ }
145
+ const event = this.toEvent(data);
146
+ if (event) {
147
+ this.apply(event);
148
+ }
149
+ }
150
+
151
+ private handleReasonInput(data: string): void {
152
+ if (matchesKey(data, "enter")) {
153
+ this.apply({ type: "submitReason", draft: this.reasonBuffer });
154
+ return;
155
+ }
156
+ if (matchesKey(data, "escape")) {
157
+ this.reasonBuffer = "";
158
+ this.apply({ type: "cancel" });
159
+ return;
160
+ }
161
+ if (matchesKey(data, "backspace")) {
162
+ this.reasonBuffer = this.reasonBuffer.slice(0, -1);
163
+ this.requestRender();
164
+ return;
165
+ }
166
+ if (isPrintable(data)) {
167
+ this.reasonBuffer += data;
168
+ this.requestRender();
169
+ }
170
+ }
171
+
172
+ private toEvent(data: string): PromptEvent | undefined {
173
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
174
+ return { type: "nav", direction: "up" };
175
+ }
176
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
177
+ return { type: "nav", direction: "down" };
178
+ }
179
+ if (matchesKey(data, "enter")) {
180
+ return { type: "confirm" };
181
+ }
182
+ if (matchesKey(data, "escape")) {
183
+ return { type: "cancel" };
184
+ }
185
+ if (this.state.step === "decision") {
186
+ const key = OPTION_ORDER.find((option) => matchesKey(data, option));
187
+ if (key) {
188
+ return { type: "hotkey", key };
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ private apply(event: PromptEvent): void {
195
+ const outcome = reducePrompt(this.config, this.state, event);
196
+ if (outcome.kind === "decision") {
197
+ this.done(outcome.decision);
198
+ return;
199
+ }
200
+ if (outcome.state.step === "reason" && this.state.step !== "reason") {
201
+ this.reasonBuffer = "";
202
+ }
203
+ this.state = outcome.state;
204
+ this.requestRender();
205
+ }
206
+
207
+ private renderDecision(): string[] {
208
+ const lines = [this.theme.fg("accent", this.title), this.message, ""];
209
+ for (const key of OPTION_ORDER) {
210
+ const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
211
+ const selected = this.state.highlightedKey === key;
212
+ const marker = selected ? "▶" : " ";
213
+ const row = `${marker} (${key}) ${label}`;
214
+ lines.push(selected ? this.theme.fg("accent", row) : row);
215
+ }
216
+ lines.push("");
217
+ lines.push(
218
+ this.state.hint ||
219
+ this.theme.fg(
220
+ "muted",
221
+ "↑/↓ move · enter confirm · esc deny · press a letter, then again to confirm",
222
+ ),
223
+ );
224
+ return lines;
225
+ }
226
+
227
+ private renderReason(): string[] {
228
+ const lines = [
229
+ this.theme.fg("accent", this.title),
230
+ this.message,
231
+ "",
232
+ `Reason (required): ${this.reasonBuffer}\u2588`,
233
+ ];
234
+ if (this.state.reasonError) {
235
+ lines.push(this.theme.fg("error", this.state.reasonError));
236
+ }
237
+ lines.push("");
238
+ lines.push(this.theme.fg("muted", "enter submit · esc back"));
239
+ return lines;
240
+ }
241
+
242
+ private renderScope(): string[] {
243
+ const scope = this.config.sessionScope;
244
+ const subagentLabel = scope?.subagentLabel ?? "This subagent only";
245
+ const servingLabel = scope?.servingSessionLabel ?? "The whole session";
246
+ const rows: Array<{ label: string; serving: boolean }> = [
247
+ { label: subagentLabel, serving: false },
248
+ { label: servingLabel, serving: true },
249
+ ];
250
+ const lines = [
251
+ this.theme.fg("accent", this.title),
252
+ "Apply this session grant to:",
253
+ "",
254
+ ];
255
+ for (const row of rows) {
256
+ const selected = this.state.scopeServing === row.serving;
257
+ const marker = selected ? "▶" : " ";
258
+ const text = `${marker} ${row.label}`;
259
+ lines.push(selected ? this.theme.fg("accent", text) : text);
260
+ }
261
+ lines.push("");
262
+ lines.push(this.theme.fg("muted", "↑/↓ move · enter confirm · esc back"));
263
+ return lines;
264
+ }
265
+ }
266
+
267
+ function isPrintable(data: string): boolean {
268
+ if (data.length !== 1) {
269
+ return false;
270
+ }
271
+ const code = data.charCodeAt(0);
272
+ return code >= 0x20 && code !== 0x7f;
273
+ }
@@ -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
+ }
@@ -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 ["debugLog", "permissionReviewLog", "yoloMode"] as const) {
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;
@@ -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(
@@ -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.",
@@ -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;
@@ -2,7 +2,6 @@ import type { BashProgram } from "#src/access-intent/bash/program";
2
2
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
- import { getNonEmptyString, toRecord } from "#src/value-guards";
6
5
  import type { GateResult } from "./descriptor";
7
6
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
8
7
  import { selectUncoveredExternalPaths } from "./external-directory-policy";
@@ -13,21 +12,21 @@ import type { ToolCallContext } from "./types";
13
12
  *
14
13
  * Reads the external paths from the injected `BashProgram` and checks whether
15
14
  * any reference directories outside the working directory. Returns `null` when the gate
16
- * does not apply (tool is not bash, no CWD, or no external paths found).
15
+ * does not apply (not a shell invocation, no command, or no external paths found).
17
16
  * Returns a `GateBypass` when all paths are allowed (by config or session rule).
18
17
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
18
+ *
19
+ * The shell command (native `bash` or an aliased shell tool) is read from the
20
+ * injected `BashProgram`, which owns the source text it was parsed from, so
21
+ * this gate does not re-derive the input field name (#574).
19
22
  */
20
23
  export function describeBashExternalDirectoryGate(
21
24
  tcc: ToolCallContext,
22
25
  bashProgram: BashProgram | null,
23
26
  resolver: ScopedPermissionResolver,
24
27
  ): GateResult {
25
- if (tcc.toolName !== "bash") return null;
26
-
27
- const command = getNonEmptyString(toRecord(tcc.input).command);
28
- if (!command) return null;
29
-
30
28
  if (!bashProgram) return null;
29
+ const command = bashProgram.commandText();
31
30
 
32
31
  const externalPaths = bashProgram.externalPaths();
33
32
  if (externalPaths.length === 0) return null;