@gotgenes/pi-permission-system 30.0.0 → 30.2.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.
@@ -22,6 +22,7 @@ import {
22
22
  type PromptModelConfig,
23
23
  type PromptViewState,
24
24
  reducePrompt,
25
+ visibleOptionKeys,
25
26
  } from "#src/authority/permission-prompt-decision";
26
27
  import {
27
28
  completeViewBudget,
@@ -127,12 +128,11 @@ const DEFAULT_SESSION_LABEL = "Yes, for this session";
127
128
  const OPTION_LABELS: Record<PromptKey, string> = {
128
129
  y: "Yes",
129
130
  s: DEFAULT_SESSION_LABEL,
131
+ b: "Yes, for this session in both directions",
130
132
  n: "No",
131
133
  r: "No, provide reason",
132
134
  };
133
135
 
134
- const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
135
-
136
136
  export function presentInlinePermissionPrompt(
137
137
  view: PermissionPromptView,
138
138
  title: string,
@@ -142,6 +142,7 @@ export function presentInlinePermissionPrompt(
142
142
  const config: PromptModelConfig = {
143
143
  doublePressToConfirm: view.doublePressToConfirm,
144
144
  sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
145
+ widthLabel: options?.sessionWidth?.label,
145
146
  sessionScope: options?.sessionScope,
146
147
  };
147
148
  return view.ui.custom<UnattributedDecision>(
@@ -330,7 +331,9 @@ class PermissionPromptComponent implements Component {
330
331
  return { type: "cancel" };
331
332
  }
332
333
  if (this.state.step === "decision") {
333
- const key = OPTION_ORDER.find((option) => matchesKey(data, option));
334
+ const key = visibleOptionKeys(this.config).find((option) =>
335
+ matchesKey(data, option),
336
+ );
334
337
  if (key) {
335
338
  return { type: "hotkey", key };
336
339
  }
@@ -354,8 +357,8 @@ class PermissionPromptComponent implements Component {
354
357
  private renderDecision(width: number): string[] {
355
358
  const ask = this.renderAsk(width);
356
359
  const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
357
- for (const key of OPTION_ORDER) {
358
- const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
360
+ for (const key of visibleOptionKeys(this.config)) {
361
+ const label = this.labelFor(key);
359
362
  const selected = this.state.highlightedKey === key;
360
363
  const marker = selected ? "▶" : " ";
361
364
  const row = `${marker} (${key}) ${label}`;
@@ -366,6 +369,16 @@ class PermissionPromptComponent implements Component {
366
369
  return lines;
367
370
  }
368
371
 
372
+ /**
373
+ * The row label for a key: the two session options carry ask-supplied text
374
+ * naming what they grant, and the rest are fixed.
375
+ */
376
+ private labelFor(key: PromptKey): string {
377
+ if (key === "s") return this.config.sessionLabel;
378
+ if (key === "b") return this.config.widthLabel ?? OPTION_LABELS.b;
379
+ return OPTION_LABELS[key];
380
+ }
381
+
369
382
  private renderReason(width: number): string[] {
370
383
  const lines = [
371
384
  this.theme.fg("accent", this.title),
@@ -1,3 +1,4 @@
1
+ import type { SessionGrantWidth } from "#src/approval-grant";
1
2
  import {
2
3
  createDeniedPermissionDecision,
3
4
  normalizePermissionDenialReason,
@@ -15,17 +16,45 @@ import {
15
16
  * forwards keystrokes to {@link reducePrompt} and renders the returned state.
16
17
  */
17
18
 
18
- /** The four decision hotkeys, in display order. */
19
- export type PromptKey = "y" | "s" | "n" | "r";
19
+ /**
20
+ * The decision hotkeys, in display order.
21
+ *
22
+ * `b` is conditional: it appears only for an ask whose session grant can be
23
+ * widened to both directions (#813), so the roster an ask actually offers
24
+ * comes from {@link visibleOptionKeys} rather than from this type.
25
+ */
26
+ export type PromptKey = "y" | "s" | "b" | "n" | "r";
20
27
 
21
28
  /** Which sub-view the dialog is showing. */
22
29
  export type PromptStep = "decision" | "reason" | "scope";
23
30
 
24
- const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
31
+ const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "b", "n", "r"];
32
+
33
+ const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
34
+ (key) => key !== "b",
35
+ );
36
+
37
+ /**
38
+ * The decision step's option keys, in display order.
39
+ *
40
+ * A function of the config rather than an exported constant, so which options
41
+ * an ask offers is decided in the model and the component renders whatever it
42
+ * is handed — two copies of the roster would be two places to teach about a
43
+ * conditional option.
44
+ *
45
+ * The width option is offered iff the ask supplied a label for it, so an ask
46
+ * that proves no single direction is rendered and navigated exactly as before.
47
+ */
48
+ export function visibleOptionKeys(
49
+ config: PromptModelConfig,
50
+ ): readonly PromptKey[] {
51
+ return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
52
+ }
25
53
 
26
54
  const OPTION_VERBS: Record<PromptKey, string> = {
27
55
  y: "approve",
28
56
  s: "approve for this session",
57
+ b: "approve both directions for this session",
29
58
  n: "deny",
30
59
  r: "deny with a reason",
31
60
  };
@@ -36,6 +65,13 @@ export interface PromptModelConfig {
36
65
  doublePressToConfirm: boolean;
37
66
  /** Label shown beside the approve-for-session option. */
38
67
  sessionLabel: string;
68
+ /**
69
+ * Label for the both-directions session option (#813).
70
+ *
71
+ * Its presence is what offers the option: an ask whose grants prove no
72
+ * single direction supplies none, and the roster stays four keys.
73
+ */
74
+ widthLabel?: string;
39
75
  /**
40
76
  * Forwarded asks only: when set, confirming `s` opens a second step choosing
41
77
  * whether the grant applies to the requesting subagent only (least-privilege
@@ -56,6 +92,15 @@ export interface PromptViewState {
56
92
  reasonError?: string;
57
93
  /** Scope step: false = subagent-only (default), true = whole serving session. */
58
94
  scopeServing: boolean;
95
+ /**
96
+ * The width the session option chosen so far would grant.
97
+ *
98
+ * Held on the state rather than passed to the scope step, because a
99
+ * forwarded ask commits the two choices in different steps. Reset to
100
+ * `"proven"` on every return to the decision step, so a width the user
101
+ * backed out of cannot ride along with a later narrow choice.
102
+ */
103
+ grantWidth: SessionGrantWidth;
59
104
  }
60
105
 
61
106
  /** An input event the reducer understands. */
@@ -81,6 +126,7 @@ export function initialPromptState(
81
126
  hint: "",
82
127
  reasonError: undefined,
83
128
  scopeServing: false,
129
+ grantWidth: "proven",
84
130
  };
85
131
  }
86
132
 
@@ -116,12 +162,14 @@ function reduceDecisionStep(
116
162
  case "nav":
117
163
  return render({
118
164
  ...state,
119
- highlightedKey: shiftKey(state.highlightedKey, event.direction),
165
+ highlightedKey: shiftKey(config, state.highlightedKey, event.direction),
120
166
  armedKey: undefined,
121
167
  hint: "",
122
168
  });
123
169
  case "hotkey":
124
- return pressHotkey(config, state, event.key);
170
+ return visibleOptionKeys(config).includes(event.key)
171
+ ? pressHotkey(config, state, event.key)
172
+ : render(state);
125
173
  case "confirm":
126
174
  return commit(config, state, state.highlightedKey);
127
175
  case "cancel":
@@ -170,23 +218,48 @@ function commit(
170
218
  reasonError: undefined,
171
219
  });
172
220
  case "s":
221
+ case "b": {
222
+ // The two session options differ only in the width they grant; which
223
+ // scope they land on is the forwarded scope step's separate question.
224
+ const grantWidth: SessionGrantWidth = key === "b" ? "family" : "proven";
173
225
  if (config.sessionScope) {
174
226
  return render({
175
227
  ...state,
176
228
  step: "scope",
177
- highlightedKey: "s",
229
+ highlightedKey: key,
178
230
  armedKey: undefined,
179
231
  hint: "",
180
232
  scopeServing: false,
233
+ grantWidth,
181
234
  });
182
235
  }
183
236
  return {
184
237
  kind: "decision",
185
- decision: { approved: true, state: "approved_for_session" },
238
+ decision: sessionDecision("approved_for_session", grantWidth),
186
239
  };
240
+ }
187
241
  }
188
242
  }
189
243
 
244
+ /**
245
+ * A session-granting decision, naming its width only when it is not the
246
+ * default.
247
+ *
248
+ * Absent means `"proven"` everywhere this value travels — the decision, the
249
+ * gate result, and the forwarded wire — so the narrow grant serializes
250
+ * exactly as it did before the option existed.
251
+ */
252
+ function sessionDecision(
253
+ state: "approved_for_session" | "approved_for_serving_session",
254
+ width: SessionGrantWidth,
255
+ ): UnattributedDecision {
256
+ return {
257
+ approved: true,
258
+ state,
259
+ ...(width === "family" ? { sessionGrantWidth: width } : {}),
260
+ };
261
+ }
262
+
190
263
  function reduceReasonStep(
191
264
  state: PromptViewState,
192
265
  event: PromptEvent,
@@ -198,6 +271,7 @@ function reduceReasonStep(
198
271
  armedKey: undefined,
199
272
  hint: "",
200
273
  reasonError: undefined,
274
+ grantWidth: "proven",
201
275
  });
202
276
  }
203
277
  if (event.type === "submitReason") {
@@ -226,12 +300,12 @@ function reduceScopeStep(
226
300
  case "confirm":
227
301
  return {
228
302
  kind: "decision",
229
- decision: {
230
- approved: true,
231
- state: state.scopeServing
303
+ decision: sessionDecision(
304
+ state.scopeServing
232
305
  ? "approved_for_serving_session"
233
306
  : "approved_for_session",
234
- },
307
+ state.grantWidth,
308
+ ),
235
309
  };
236
310
  case "cancel":
237
311
  return render({
@@ -239,17 +313,23 @@ function reduceScopeStep(
239
313
  step: "decision",
240
314
  armedKey: undefined,
241
315
  hint: "",
316
+ grantWidth: "proven",
242
317
  });
243
318
  default:
244
319
  return render(state);
245
320
  }
246
321
  }
247
322
 
248
- function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
249
- const index = OPTION_ORDER.indexOf(current);
323
+ function shiftKey(
324
+ config: PromptModelConfig,
325
+ current: PromptKey,
326
+ direction: "up" | "down",
327
+ ): PromptKey {
328
+ const keys = visibleOptionKeys(config);
329
+ const index = keys.indexOf(current);
250
330
  const delta = direction === "down" ? 1 : -1;
251
- const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
252
- return OPTION_ORDER[next] ?? current;
331
+ const next = (index + delta + keys.length) % keys.length;
332
+ return keys[next] ?? current;
253
333
  }
254
334
 
255
335
  function render(state: PromptViewState): PromptOutcome {
@@ -1,3 +1,4 @@
1
+ import type { SessionGrantWidth } from "#src/approval-grant";
1
2
  import type { DecisionSource } from "#src/authority/decision-source";
2
3
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
4
  import type {
@@ -11,6 +12,25 @@ import type { TerminalAuthorizer } from "./authorizer";
11
12
 
12
13
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
13
14
 
15
+ /**
16
+ * The width a decision's session grant was recorded at, or `undefined` when it
17
+ * granted nothing for the session.
18
+ *
19
+ * Absent means "proven" everywhere else this value travels, but the review log
20
+ * is read rather than consumed, so a session-granting entry states its width
21
+ * explicitly instead of leaving the reader to know the default (#813).
22
+ */
23
+ function recordedGrantWidth(
24
+ decision: PermissionPromptDecision,
25
+ ): SessionGrantWidth | undefined {
26
+ const grantsForSession =
27
+ decision.state === "approved_for_session" ||
28
+ decision.state === "approved_for_serving_session";
29
+ return grantsForSession
30
+ ? (decision.sessionGrantWidth ?? "proven")
31
+ : undefined;
32
+ }
33
+
14
34
  /**
15
35
  * Provenance of a forwarded ask: who is really asking, one hop below.
16
36
  *
@@ -133,6 +153,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
133
153
  : decision.state,
134
154
  denialReason: decision.denialReason,
135
155
  decidedBy: decision.decidedBy,
156
+ sessionGrantWidth: recordedGrantWidth(decision),
136
157
  },
137
158
  );
138
159
 
@@ -151,10 +172,14 @@ export class PermissionPrompter implements PermissionPrompterApi {
151
172
  resolution?: string;
152
173
  denialReason?: string;
153
174
  decidedBy?: DecisionSource;
175
+ sessionGrantWidth?: SessionGrantWidth;
154
176
  },
155
177
  ): void {
156
178
  this.deps.logger.review(event, {
157
179
  ...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
180
+ ...(details.sessionGrantWidth
181
+ ? { sessionGrantWidth: details.sessionGrantWidth }
182
+ : {}),
158
183
  requestId: details.requestId,
159
184
  source: details.source,
160
185
  agentName: details.agentName,
@@ -8,7 +8,6 @@ import type { PermissionSession } from "#src/permission-session";
8
8
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
9
9
  import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
10
10
  import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
11
- import type { PermissionState } from "#src/types";
12
11
 
13
12
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
14
13
  interface BeforeAgentStartPayload {
@@ -17,16 +16,17 @@ interface BeforeAgentStartPayload {
17
16
 
18
17
  /**
19
18
  * Pure helper: returns true when the tool should be exposed to the agent.
20
- * Checks the tool-level permission (not command-level) so that a blanket
21
- * `bash: deny` hides the tool entirely before any invocation is attempted.
19
+ *
20
+ * A tool is withheld only when *every* value under its surface resolves to
21
+ * `deny`, so a blanket `bash: deny` hides the tool entirely while a partially
22
+ * permissive `bash: {"*": "deny", "git *": "ask"}` keeps it reachable (#815).
22
23
  */
23
24
  export function shouldExposeTool(
24
25
  toolName: string,
25
26
  agentName: string | null,
26
- getToolPermission: (toolName: string, agentName?: string) => PermissionState,
27
+ isToolFullyDenied: (toolName: string, agentName?: string) => boolean,
27
28
  ): boolean {
28
- const toolPermission = getToolPermission(toolName, agentName ?? undefined);
29
- return toolPermission !== "deny";
29
+ return !isToolFullyDenied(toolName, agentName ?? undefined);
30
30
  }
31
31
 
32
32
  /**
@@ -41,7 +41,7 @@ export function shouldExposeTool(
41
41
  * - `turnPrep` — brings the node up to date for the turn before anything reads
42
42
  * session state
43
43
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
- * - `resolver` — owns permission-query surface: `getToolPermission`, skill check
44
+ * - `resolver` — owns permission-query surface: `isToolFullyDenied`, skill check
45
45
  * - `toolRegistry` — Pi tool API subset (getActive + setActive)
46
46
  */
47
47
  export class AgentPrepHandler {
@@ -70,7 +70,7 @@ export class AgentPrepHandler {
70
70
  }
71
71
  if (
72
72
  shouldExposeTool(toolName, agentName, (t, a) =>
73
- this.resolver.getToolPermission(t, a),
73
+ this.resolver.isToolFullyDenied(t, a),
74
74
  )
75
75
  ) {
76
76
  allowedTools.push(toolName);
@@ -228,9 +228,9 @@ export class GateRunner {
228
228
  messages,
229
229
  });
230
230
 
231
- // 4. Determine whether session approval was granted
232
- const hasSessionApproval =
233
- gateResult.action === "allow" && gateResult.forSession === true;
231
+ // 4. Determine whether session approval was granted, and at what width
232
+ const sessionGrant =
233
+ gateResult.action === "allow" ? gateResult.sessionGrant : undefined;
234
234
 
235
235
  // 5. Emit decision event
236
236
  this.emitDecision(
@@ -242,15 +242,17 @@ export class GateRunner {
242
242
  gateResult.action === "allow" ? "allow" : "deny",
243
243
  resolutionFor(gateResult.decidedBy, {
244
244
  approved: gateResult.action === "allow",
245
- forSession: hasSessionApproval,
245
+ forSession: sessionGrant !== undefined,
246
246
  }),
247
247
  ),
248
248
  );
249
249
 
250
250
  // 6. Record session approval — tell the store; it owns the per-pattern loop
251
- // hasSessionApproval already implies gateResult.action === "allow"
252
- if (hasSessionApproval && descriptor.sessionApproval) {
253
- this.recorder.recordSessionApproval(descriptor.sessionApproval);
251
+ // A present grant already implies gateResult.action === "allow".
252
+ if (sessionGrant && descriptor.sessionApproval) {
253
+ this.recorder.recordSessionApproval(
254
+ descriptor.sessionApproval.atWidth(sessionGrant.width),
255
+ );
254
256
  }
255
257
 
256
258
  if (gateResult.action === "block") {
@@ -1,4 +1,9 @@
1
- import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
1
+ import {
2
+ type CapabilityDirection,
3
+ PATH_BEARING_TOOLS,
4
+ surfaceFamilyOf,
5
+ } from "./access-intent/path-surfaces";
6
+ import type { ApprovalGrant } from "./approval-grant";
2
7
  import { prefix, stripBashCommentLines } from "./bash-arity";
3
8
 
4
9
  /** The suggestion returned for a "Yes, for this session" dialog option. */
@@ -69,27 +74,90 @@ export interface ForwardedScopeLabels {
69
74
  servingSessionLabel: string;
70
75
  }
71
76
 
77
+ /**
78
+ * What an approval's grants cover, as one phrase.
79
+ *
80
+ * A single grant names its pattern; several name their count, because only the
81
+ * external-directory gate aggregates an ask over many paths and there is no
82
+ * pattern that describes them all. Requires at least one grant — an approval
83
+ * with none is never offered as a session option.
84
+ */
85
+ export function describeGrantTarget(grants: readonly ApprovalGrant[]): string {
86
+ return grants.length === 1
87
+ ? `"${grants[0].pattern}"`
88
+ : `${grants.length} paths`;
89
+ }
90
+
91
+ /** The two session-option labels for an ask whose grants prove one direction. */
92
+ export interface DirectionalSessionLabels {
93
+ /** The proven-direction grant — the least-privilege default. */
94
+ sessionLabel: string;
95
+ /** The both-directions grant, offered beside it (#813). */
96
+ widenedLabel: string;
97
+ }
98
+
99
+ const DIRECTION_NOUNS: Record<CapabilityDirection, string> = {
100
+ read: "reads",
101
+ write: "writes",
102
+ };
103
+
104
+ /**
105
+ * Label the two widths a directional ask's session grant can take.
106
+ *
107
+ * Both rows name the direction and the target, so the choice between them
108
+ * contrasts on a stated axis rather than on "wider" (#813).
109
+ */
110
+ export function buildDirectionalSessionLabels(
111
+ direction: CapabilityDirection,
112
+ target: string,
113
+ ): DirectionalSessionLabels {
114
+ return {
115
+ sessionLabel: `Yes, allow ${DIRECTION_NOUNS[direction]} to ${target} for this session`,
116
+ widenedLabel: `Yes, allow ${DIRECTION_NOUNS.read} and ${DIRECTION_NOUNS.write} to ${target} for this session`,
117
+ };
118
+ }
119
+
72
120
  /**
73
121
  * Build the two scope labels shown when a human grants a forwarded request
74
122
  * "for this session."
75
123
  *
76
124
  * The subagent option names the requester (least privilege); the whole-session
77
- * option restates the surface + pattern being granted session-wide.
125
+ * option restates what is being granted session-wide — every grant, not the
126
+ * first of them (the residual #810 deferred here).
127
+ *
128
+ * The surface is named as the grants' shared **family**, never a directional
129
+ * member: these labels are built before the dialog runs, so a direction here
130
+ * could contradict a width the human chooses inside it. Grants that share no
131
+ * family name no surface at all.
78
132
  */
79
133
  export function buildForwardedScopeLabels(
80
134
  agentName: string | null,
81
- surface: string,
82
- pattern: string,
135
+ grants: readonly ApprovalGrant[],
83
136
  ): ForwardedScopeLabels {
84
137
  const subagentLabel = agentName
85
138
  ? `This subagent ('${agentName}') only`
86
139
  : "This subagent only";
140
+ const family = sharedSurfaceFamilyOf(grants);
141
+ const granted = family
142
+ ? `${family} ${describeGrantTarget(grants)}`
143
+ : describeGrantTarget(grants);
87
144
  return {
88
145
  subagentLabel,
89
- servingSessionLabel: `The whole session — allow ${surface} "${pattern}" for parent and all subagents`,
146
+ servingSessionLabel: `The whole session — allow ${granted} for parent and all subagents`,
90
147
  };
91
148
  }
92
149
 
150
+ /** The family every grant belongs to, or `null` when they disagree. */
151
+ function sharedSurfaceFamilyOf(
152
+ grants: readonly ApprovalGrant[],
153
+ ): string | null {
154
+ if (grants.length === 0) return null;
155
+ const family = surfaceFamilyOf(grants[0].surface);
156
+ return grants.every((grant) => surfaceFamilyOf(grant.surface) === family)
157
+ ? family
158
+ : null;
159
+ }
160
+
93
161
  /** Surface-aware human-readable labels for the session-approval option. */
94
162
  function buildLabel(pattern: string, surface: string): string {
95
163
  switch (surface) {
@@ -1,3 +1,4 @@
1
+ import type { SessionGrantWidth } from "#src/approval-grant";
1
2
  import type { DecisionSource } from "#src/authority/decision-source";
2
3
  import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
3
4
 
@@ -13,8 +14,15 @@ export type PermissionGateResult =
13
14
  | {
14
15
  action: "allow";
15
16
  decidedBy: DecisionSource;
16
- /** Set when the human granted the ask for the whole session. */
17
- forSession?: true;
17
+ /**
18
+ * Set when the human granted the ask for the whole session, carrying the
19
+ * width to record it at.
20
+ *
21
+ * One field rather than a `forSession` flag beside a width: the width is
22
+ * meaningless without the grant, and two optional fields could represent
23
+ * a width for a grant that never happened.
24
+ */
25
+ sessionGrant?: { width: SessionGrantWidth };
18
26
  }
19
27
  | { action: "block"; decidedBy: DecisionSource; reason: string };
20
28
 
@@ -112,7 +120,12 @@ export async function applyPermissionGate(
112
120
  decision.state === "approved_for_session" &&
113
121
  params.canGrantForSession
114
122
  ) {
115
- return { action: "allow", decidedBy, forSession: true };
123
+ return {
124
+ action: "allow",
125
+ decidedBy,
126
+ // Absent means the width every producer chose before #813.
127
+ sessionGrant: { width: decision.sessionGrantWidth ?? "proven" },
128
+ };
116
129
  }
117
130
  return { action: "allow", decidedBy };
118
131
  }
@@ -22,6 +22,7 @@ import {
22
22
  evaluateAnyValue,
23
23
  evaluateFirst,
24
24
  floorAllowsToAsk,
25
+ isSurfaceFullyDenied,
25
26
  rewriteAsksToYolo,
26
27
  } from "./rule";
27
28
  import { mergeScopesWithOrigins } from "./scope-merge";
@@ -83,6 +84,7 @@ export interface ScopedPermissionManager {
83
84
  sessionRules?: Ruleset,
84
85
  ): PermissionCheckResult;
85
86
  getToolPermission(toolName: string, agentName?: string): PermissionState;
87
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
86
88
  getConfigIssues(agentName?: string): string[];
87
89
  }
88
90
 
@@ -268,6 +270,24 @@ export class PermissionManager implements ScopedPermissionManager {
268
270
  return evaluate(toolName.trim(), "*", composedRules, this.flavor).action;
269
271
  }
270
272
 
273
+ /**
274
+ * Whether every value under a tool's surface resolves to `deny`.
275
+ *
276
+ * This is the question tool exposure asks, and it is not
277
+ * {@link PermissionManager.getToolPermission} — that reports the surface's
278
+ * catch-all, so `bash: {"*": "deny", "git *": "ask"}` reads as `deny` even
279
+ * though `git status` would be asked about (#815).
280
+ *
281
+ * Reads the same composed rules the catch-all query does, so it inherits the
282
+ * fail-closed floor and not the yolo rewrite. Neither matters: one touches
283
+ * only `allow` and the other only `ask`, so neither can create or remove the
284
+ * `deny` this answer turns on.
285
+ */
286
+ isToolFullyDenied(toolName: string, agentName?: string): boolean {
287
+ const { composedRules } = this.resolvePermissions(agentName);
288
+ return isSurfaceFullyDenied(toolName.trim(), composedRules, this.flavor);
289
+ }
290
+
271
291
  /**
272
292
  * Unified resolution entry point — dispatches on intent kind.
273
293
  *
@@ -126,10 +126,19 @@ export class PermissionResolver
126
126
  );
127
127
  }
128
128
 
129
+ // Reached only through `LocalPermissionsService`'s structural resolver view,
130
+ // which fallow cannot trace; `tsc` enforces it at that constructor. The
131
+ // handler's exposure check moved to `isToolFullyDenied` in #815, leaving this
132
+ // the published cross-extension catch-all query and nothing else.
133
+ // fallow-ignore-next-line unused-class-member
129
134
  getToolPermission(toolName: string, agentName?: string): PermissionState {
130
135
  return this.permissionManager.getToolPermission(toolName, agentName);
131
136
  }
132
137
 
138
+ isToolFullyDenied(toolName: string, agentName?: string): boolean {
139
+ return this.permissionManager.isToolFullyDenied(toolName, agentName);
140
+ }
141
+
133
142
  getConfigIssues(agentName?: string): string[] {
134
143
  return this.permissionManager.getConfigIssues(agentName);
135
144
  }
@@ -25,6 +25,7 @@ import type { PermissionCheckResult, PermissionState } from "./types";
25
25
  interface ResolverForService {
26
26
  resolve(intent: AccessIntent): PermissionCheckResult;
27
27
  getToolPermission(toolName: string, agentName?: string): PermissionState;
28
+ isToolFullyDenied(toolName: string, agentName?: string): boolean;
28
29
  }
29
30
 
30
31
  /** Narrow session view: hands out the cwd-bound path normalizer. */
@@ -81,6 +82,13 @@ export class LocalPermissionsService implements PermissionsService {
81
82
  return this.resolver.getToolPermission(toolName, agentName);
82
83
  }
83
84
 
85
+ isToolFullyDenied(
86
+ toolName: string,
87
+ agentName?: string,
88
+ ): ReturnType<PermissionsService["isToolFullyDenied"]> {
89
+ return this.resolver.isToolFullyDenied(toolName, agentName);
90
+ }
91
+
84
92
  registerToolInputFormatter(
85
93
  toolName: string,
86
94
  formatter: ToolInputFormatter,