@gotgenes/pi-permission-system 39.1.1 → 40.0.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.
@@ -39,6 +39,19 @@ export interface PathToken {
39
39
  readonly token: string;
40
40
  readonly effect: TokenEffect;
41
41
  readonly role: TokenRole;
42
+ /**
43
+ * The source span of the argument node, set only when {@link token} is that
44
+ * whole node's resolved text. A token read from part of an argument (an
45
+ * option's embedded value, a script file glued to its flag) carries none, so
46
+ * nothing keyed on the span can mistake it for the whole word.
47
+ */
48
+ readonly span?: SourceSpan;
49
+ }
50
+
51
+ /** Where a node sits in the parsed source: `startIndex` to `endIndex`. */
52
+ export interface SourceSpan {
53
+ readonly start: number;
54
+ readonly end: number;
42
55
  }
43
56
 
44
57
  // ── Public surface ─────────────────────────────────────────────────────────
@@ -160,7 +173,7 @@ export function collectRedirectTokens(
160
173
  i === target && provesTarget(effect, child, token, words)
161
174
  ? "redirect-destination"
162
175
  : "operand";
163
- tokens.push({ token, effect, role });
176
+ tokens.push(wholeArgumentToken(child, token, effect, role));
164
177
  }
165
178
  }
166
179
  tokens.push(...collectHostedExecutionTokens(child, words));
@@ -271,11 +284,9 @@ function collectStatementOperandTokens(
271
284
  tokens.push(...collectPathCandidateTokens(child, words));
272
285
  continue;
273
286
  }
274
- tokens.push({
275
- token: words.text(child),
276
- effect: UNPROVEN_EFFECT,
277
- role: "operand",
278
- });
287
+ tokens.push(
288
+ wholeArgumentToken(child, words.text(child), UNPROVEN_EFFECT, "operand"),
289
+ );
279
290
  tokens.push(...collectHostedExecutionTokens(child, words));
280
291
  }
281
292
  return tokens;
@@ -833,7 +844,12 @@ function collectPatternCommandTokens(
833
844
  tokens.push(...collectPathCandidateTokens(child, words));
834
845
  continue;
835
846
  }
836
- const discharge = dischargePendingConsumption(consumption, text, effect);
847
+ const discharge = dischargePendingConsumption(
848
+ consumption,
849
+ child,
850
+ text,
851
+ effect,
852
+ );
837
853
  if (discharge.token) tokens.push(discharge.token);
838
854
  if (discharge.consumed) continue;
839
855
  }
@@ -907,7 +923,7 @@ function collectPatternCommandTokens(
907
923
  if (!hasExplicitScript && positionalsSeen < patternPositionals) {
908
924
  positionalsSeen++; // Skip: this is an inline pattern/script.
909
925
  } else {
910
- tokens.push({ token: text, effect, role: "operand" });
926
+ tokens.push(wholeArgumentToken(child, text, effect, "operand"));
911
927
  }
912
928
  // A quoted token that did not act as a flag above has its embedded value
913
929
  // split here instead.
@@ -957,6 +973,7 @@ interface ConsumptionDischarge {
957
973
  /** Apply a pending consumption to the argument text that follows its flag. */
958
974
  function dischargePendingConsumption(
959
975
  role: PatternFlagRole,
976
+ argument: TSNode,
960
977
  text: string,
961
978
  effect: TokenEffect,
962
979
  ): ConsumptionDischarge {
@@ -964,7 +981,7 @@ function dischargePendingConsumption(
964
981
  case "script-file":
965
982
  return {
966
983
  consumed: true,
967
- token: { token: text, effect, role: "operand" },
984
+ token: wholeArgumentToken(argument, text, effect, "operand"),
968
985
  };
969
986
  case "script":
970
987
  case "value":
@@ -1015,7 +1032,9 @@ function collectGenericCommandTokens(
1015
1032
 
1016
1033
  // Argument nodes: resolve their text and collect.
1017
1034
  if (ARG_NODE_TYPES.has(child.type)) {
1018
- tokens.push({ token: words.text(child), effect, role: "operand" });
1035
+ tokens.push(
1036
+ wholeArgumentToken(child, words.text(child), effect, "operand"),
1037
+ );
1019
1038
  continue;
1020
1039
  }
1021
1040
 
@@ -1025,3 +1044,21 @@ function collectGenericCommandTokens(
1025
1044
 
1026
1045
  return tokens;
1027
1046
  }
1047
+
1048
+ /**
1049
+ * A token that is `node`'s whole resolved text, carrying the node's source span
1050
+ * so a reader of the span knows the token stands for the entire word.
1051
+ */
1052
+ function wholeArgumentToken(
1053
+ node: TSNode,
1054
+ token: string,
1055
+ effect: TokenEffect,
1056
+ role: TokenRole,
1057
+ ): PathToken {
1058
+ return {
1059
+ token,
1060
+ effect,
1061
+ role,
1062
+ span: { start: node.startIndex, end: node.endIndex },
1063
+ };
1064
+ }
@@ -57,7 +57,9 @@ export interface InboxProcessor {
57
57
  * used as-is, never re-derived through this session's `PathNormalizer`/cwd.
58
58
  *
59
59
  * The answer honors the floor the child raised (`intent.floor`): a serving
60
- * `allow` rule does not answer an ask the child's own gate floored.
60
+ * `allow` rule does not answer an ask the child's own gate floored. A bash
61
+ * chain is judged on every unit the child left asking (`intent.askingUnits`),
62
+ * each with its own floor, since this answer approves the whole tool call.
61
63
  *
62
64
  * Narrow by design (ISP): the server needs one decision, not the whole
63
65
  * resolver. The composition root satisfies it with `ResolverServingPolicy`
@@ -155,6 +157,9 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
155
157
  matchValues: intent.matchValues,
156
158
  boundaryValue: intent.boundaryValue,
157
159
  ...(intent.floor === undefined ? {} : { floor: intent.floor }),
160
+ ...(intent.askingUnits === undefined
161
+ ? {}
162
+ : { askingUnits: intent.askingUnits }),
158
163
  };
159
164
  }
160
165
 
@@ -19,6 +19,7 @@ import {
19
19
  type ApprovalGrant,
20
20
  isSessionGrantWidth,
21
21
  } from "#src/session/approval-grant";
22
+ import type { AskingBashUnit } from "#src/types";
22
23
  import { asDecisionSource } from "./decision-source";
23
24
  import { isPermissionDecisionState } from "./permission-dialog";
24
25
  import {
@@ -125,6 +126,7 @@ function asForwardedAccessIntent(
125
126
  matchValues?: unknown;
126
127
  boundaryValue?: unknown;
127
128
  floor?: unknown;
129
+ askingUnits?: unknown;
128
130
  requesterCwd?: unknown;
129
131
  principal?: unknown;
130
132
  };
@@ -139,6 +141,12 @@ function asForwardedAccessIntent(
139
141
  // A malformed floor drops the whole intent, which escalates: it must
140
142
  // never read as "no floor".
141
143
  !(candidate.floor === undefined || typeof candidate.floor === "string") ||
144
+ // Likewise a malformed unit list: it must never read as "no units", which
145
+ // would judge the chain's winner alone.
146
+ !(
147
+ candidate.askingUnits === undefined ||
148
+ isWellFormedAskingUnits(candidate.askingUnits)
149
+ ) ||
142
150
  typeof candidate.requesterCwd !== "string" ||
143
151
  typeof candidate.principal !== "object" ||
144
152
  candidate.principal === null
@@ -160,6 +168,9 @@ function asForwardedAccessIntent(
160
168
  matchValues: [...candidate.matchValues],
161
169
  boundaryValue: candidate.boundaryValue,
162
170
  ...(candidate.floor === undefined ? {} : { floor: candidate.floor }),
171
+ ...(isWellFormedAskingUnits(candidate.askingUnits)
172
+ ? { askingUnits: copyAskingUnits(candidate.askingUnits) }
173
+ : {}),
163
174
  requesterCwd: candidate.requesterCwd,
164
175
  principal: {
165
176
  sessionId: principal.sessionId,
@@ -168,6 +179,39 @@ function asForwardedAccessIntent(
168
179
  };
169
180
  }
170
181
 
182
+ /**
183
+ * True for a non-empty list of `{ command: string, floor?: string }` units.
184
+ *
185
+ * Empty is malformed: a child stamps the list only when it holds a unit, so an
186
+ * empty one says nothing a well-formed request would.
187
+ */
188
+ function isWellFormedAskingUnits(
189
+ value: unknown,
190
+ ): value is readonly { command: string; floor?: string }[] {
191
+ return (
192
+ Array.isArray(value) &&
193
+ value.length > 0 &&
194
+ value.every(
195
+ (unit: unknown) =>
196
+ typeof unit === "object" &&
197
+ unit !== null &&
198
+ typeof (unit as { command?: unknown }).command === "string" &&
199
+ ((unit as { floor?: unknown }).floor === undefined ||
200
+ typeof (unit as { floor?: unknown }).floor === "string"),
201
+ )
202
+ );
203
+ }
204
+
205
+ /** Rebuild each unit field by field, so no unread key rides through. */
206
+ function copyAskingUnits(
207
+ units: readonly { command: string; floor?: string }[],
208
+ ): AskingBashUnit[] {
209
+ return units.map((unit) => ({
210
+ command: unit.command,
211
+ ...(unit.floor === undefined ? {} : { floor: unit.floor }),
212
+ }));
213
+ }
214
+
171
215
  export function formatUnknownErrorMessage(error: unknown): string {
172
216
  if (error instanceof Error && error.message) {
173
217
  return error.message;
@@ -5,6 +5,7 @@ import type {
5
5
  ApprovalGrant,
6
6
  SessionGrantWidth,
7
7
  } from "#src/session/approval-grant";
8
+ import type { AskingBashUnit } from "#src/types";
8
9
  import type { DecisionSource } from "./decision-source";
9
10
  import type { PermissionDecisionState } from "./permission-dialog";
10
11
  import type { SubagentSessionRegistry } from "./subagent-registry";
@@ -131,6 +132,14 @@ export interface ForwardedAccessFacts {
131
132
  * than recomputing it. Absent when no floor was raised.
132
133
  */
133
134
  floor?: string;
135
+ /**
136
+ * Every unit of a bash chain the child left asking, each with its own floor.
137
+ * `matchValues` names only the chain's winner, and the serving node's answer
138
+ * approves the whole tool call, so it judges each of these instead. Absent
139
+ * for a non-bash ask and for a bash ask raised without a chain (the empty
140
+ * parse), where `matchValues` is the whole command.
141
+ */
142
+ askingUnits?: AskingBashUnit[];
134
143
  }
135
144
 
136
145
  /**
@@ -115,7 +115,30 @@ export function resolveBashCommandCheck(
115
115
  const winner =
116
116
  pickMostRestrictive(results) ??
117
117
  resolveOnBashSurface(command, [], agentName, resolver);
118
- return withChainFloor(winner, results);
118
+ return withAskingUnits(withChainFloor(winner, results), results);
119
+ }
120
+
121
+ /**
122
+ * List on an asking winner every unit of the chain the gate left asking.
123
+ *
124
+ * The winner names one command, but a forwarded ask's answer approves the
125
+ * whole tool call, so the serving node has to judge every unit the child could
126
+ * not resolve, each with its own floor (#1030). A unit the child's rules
127
+ * allowed stays home, and so does one the session already granted: the user
128
+ * approved exactly that command.
129
+ */
130
+ function withAskingUnits(
131
+ winner: PermissionCheckResult,
132
+ results: readonly PermissionCheckResult[],
133
+ ): PermissionCheckResult {
134
+ if (winner.state !== "ask") return winner;
135
+ const askingUnits = results
136
+ .filter((result) => result.state === "ask" && result.source !== "session")
137
+ .map((result) => ({
138
+ command: result.command ?? "",
139
+ ...(result.floor === undefined ? {} : { floor: result.floor }),
140
+ }));
141
+ return askingUnits.length === 0 ? winner : { ...winner, askingUnits };
119
142
  }
120
143
 
121
144
  /**
@@ -205,14 +228,16 @@ function floorUnparsedUnit(
205
228
  * it.
206
229
  *
207
230
  * Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
208
- * session fast path.
231
+ * session fast path. Drops `matchedSpelling`: it names what the replaced rule
232
+ * matched, and beside the sentinel it would name a match that did not decide.
209
233
  */
210
234
  function floorToAsk(
211
235
  resolved: PermissionCheckResult,
212
236
  sentinel: string,
213
237
  ): PermissionCheckResult {
238
+ const { matchedSpelling: _replaced, ...unspelled } = resolved;
214
239
  return {
215
- ...resolved,
240
+ ...unspelled,
216
241
  state: "ask",
217
242
  matchedPattern: sentinel,
218
243
  floor: sentinel,
@@ -14,7 +14,7 @@ import {
14
14
  import { buildToolAskPayload } from "#src/presentation/tool-ask-payload";
15
15
  import { SessionApproval } from "#src/session/session-approval";
16
16
  import type { ToolPreviewFormatter } from "#src/tool-input/tool-preview-formatter";
17
- import type { PermissionCheckResult } from "#src/types";
17
+ import type { AskingBashUnit, PermissionCheckResult } from "#src/types";
18
18
  import type { GateDescriptor } from "./descriptor";
19
19
  import {
20
20
  accessFactsFromPath,
@@ -180,6 +180,7 @@ export function describeToolGate(
180
180
  : {
181
181
  ...accessFactsFromValue(gateSurface, decisionValue),
182
182
  ...floorFact(check),
183
+ ...askingUnitsFact(check),
183
184
  };
184
185
 
185
186
  return {
@@ -241,3 +242,16 @@ function floorExemptionFact(
241
242
  function floorFact(check: PermissionCheckResult): { floor?: string } {
242
243
  return check.floor === undefined ? {} : { floor: check.floor };
243
244
  }
245
+
246
+ /**
247
+ * Every unit of a bash chain the child left asking, as a child-fixed fact for
248
+ * the wire, so the serving node judges each command rather than the winner
249
+ * alone (#1030). Absent when the check lists none.
250
+ */
251
+ function askingUnitsFact(check: PermissionCheckResult): {
252
+ askingUnits?: AskingBashUnit[];
253
+ } {
254
+ return check.askingUnits === undefined
255
+ ? {}
256
+ : { askingUnits: [...check.askingUnits] };
257
+ }
@@ -42,6 +42,7 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
42
42
  export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
43
43
  "command",
44
44
  "executedUnit",
45
+ "matchedSpelling",
45
46
  ]);
46
47
 
47
48
  /** A range of the command to replace, and what to put in its place. */
@@ -376,7 +376,7 @@ export class PermissionManager implements ScopedPermissionManager {
376
376
  intent.command,
377
377
  intent.spellings,
378
378
  );
379
- return buildCheckResult(
379
+ const { result, matchedValue } = evaluateCheck(
380
380
  surface,
381
381
  values,
382
382
  resultExtras,
@@ -385,6 +385,12 @@ export class PermissionManager implements ScopedPermissionManager {
385
385
  fullRules,
386
386
  this.flavor,
387
387
  );
388
+ // `values[0]` is the unit as typed, and the evaluator reports the first
389
+ // value the winning rule matches, so any other value is a spelling the
390
+ // typed text did not match.
391
+ return matchedValue === values[0]
392
+ ? result
393
+ : { ...result, matchedSpelling: matchedValue };
388
394
  }
389
395
 
390
396
  // kind === "tool"
@@ -423,6 +429,30 @@ function buildCheckResult(
423
429
  fullRules: Ruleset,
424
430
  flavor: PathFlavor,
425
431
  ): PermissionCheckResult {
432
+ return evaluateCheck(
433
+ surface,
434
+ values,
435
+ resultExtras,
436
+ normalizedToolName,
437
+ toolName,
438
+ fullRules,
439
+ flavor,
440
+ ).result;
441
+ }
442
+
443
+ /**
444
+ * {@link buildCheckResult}, plus the candidate value the decision was reported
445
+ * under — for a caller that knows what that value means on its own surface.
446
+ */
447
+ function evaluateCheck(
448
+ surface: string,
449
+ values: string[],
450
+ resultExtras: Record<string, unknown>,
451
+ normalizedToolName: string,
452
+ toolName: string,
453
+ fullRules: Ruleset,
454
+ flavor: PathFlavor,
455
+ ): { result: PermissionCheckResult; matchedValue: string } {
426
456
  const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
427
457
 
428
458
  // For MCP, replace the normalizer's fallback target with the actual
@@ -433,16 +463,19 @@ function buildCheckResult(
433
463
  : resultExtras;
434
464
 
435
465
  return {
436
- toolName,
437
- state: rule.action,
438
- reason: rule.reason,
439
- matchedPattern:
440
- rule.layer === "config" || rule.layer === "session"
441
- ? rule.pattern
442
- : undefined,
443
- source: deriveSource(rule, normalizedToolName),
444
- origin: rule.origin,
445
- ...extras,
466
+ result: {
467
+ toolName,
468
+ state: rule.action,
469
+ reason: rule.reason,
470
+ matchedPattern:
471
+ rule.layer === "config" || rule.layer === "session"
472
+ ? rule.pattern
473
+ : undefined,
474
+ source: deriveSource(rule, normalizedToolName),
475
+ origin: rule.origin,
476
+ ...extras,
477
+ },
478
+ matchedValue: value,
446
479
  };
447
480
  }
448
481
 
@@ -1,8 +1,9 @@
1
1
  import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
2
2
  import type { ServingPolicy } from "#src/authority/forwarded-request-server";
3
3
  import type { ForwardedAccessIntent } from "#src/authority/permission-forwarding";
4
- import type { PermissionCheckResult } from "#src/types";
4
+ import type { AskingBashUnit, PermissionCheckResult } from "#src/types";
5
5
  import type { PermissionResolver } from "./permission-resolver";
6
+ import { mostRestrictiveOf } from "./restrictiveness";
6
7
 
7
8
  /**
8
9
  * The serving node's recorded authority over a forwarded request: the
@@ -10,6 +11,12 @@ import type { PermissionResolver } from "./permission-resolver";
10
11
  * agent-scoped to the requester (`principal.agentName`, §3), with the floor the
11
12
  * child raised honored.
12
13
  *
14
+ * A bash chain is judged unit by unit: the child names its winner in
15
+ * `matchValues`, but this answer approves the whole tool call, so every unit the
16
+ * child left asking (`askingUnits`) is resolved with its own floor and the most
17
+ * restrictive answer decides (#1030). A request without units (an older child,
18
+ * or an ask raised without a chain) is judged on its single value.
19
+ *
13
20
  * The `matchValues` are used as the child fixed them, never re-derived through
14
21
  * this session's `PathNormalizer`/cwd. The resolver's surface-family fold is
15
22
  * what keeps a parent's bare `path` deny answering a child's request, which is
@@ -22,6 +29,38 @@ export class ResolverServingPolicy implements ServingPolicy {
22
29
  ) {}
23
30
 
24
31
  resolve(intent: ForwardedAccessIntent): PermissionCheckResult {
32
+ const units = intent.askingUnits ?? [];
33
+ const first = units.at(0);
34
+ if (first === undefined) return this.resolveValue(intent);
35
+ const judge = (unit: AskingBashUnit): PermissionCheckResult =>
36
+ this.resolveUnit(unit, intent.principal.agentName);
37
+ return mostRestrictiveOf([judge(first), ...units.slice(1).map(judge)]);
38
+ }
39
+
40
+ /**
41
+ * Judge one unit the child left asking, with that unit's own floor.
42
+ *
43
+ * Resolved as the same `bash-command` intent the child's gate emitted for
44
+ * it, so a serving rule matches the unit exactly as a local one would.
45
+ */
46
+ private resolveUnit(
47
+ unit: AskingBashUnit,
48
+ agentName: string,
49
+ ): PermissionCheckResult {
50
+ const check = this.resolver.resolve({
51
+ kind: "bash-command",
52
+ surface: "bash",
53
+ command: unit.command,
54
+ spellings: [],
55
+ agentName,
56
+ });
57
+ return honorChildFloor(check, unit.floor, this.isYoloEnabled());
58
+ }
59
+
60
+ /**
61
+ * Judge the intent's single child-fixed value, with the chain-level floor.
62
+ */
63
+ private resolveValue(intent: ForwardedAccessIntent): PermissionCheckResult {
25
64
  const check = this.resolver.resolve(
26
65
  buildResolvedIntentFromMatchValues(
27
66
  intent.surface,
@@ -222,6 +222,9 @@ function coreFacts(payload: PromptPayload): Fact[] {
222
222
  if (request.matchedPattern !== null) {
223
223
  facts.push({ label: "rule", text: request.matchedPattern });
224
224
  }
225
+ if (request.matchedSpelling !== null) {
226
+ facts.push({ label: "matched as", text: request.matchedSpelling });
227
+ }
225
228
  if (request.value !== "" && request.value !== request.toolName) {
226
229
  facts.push({ label, text: request.value });
227
230
  }
@@ -56,6 +56,7 @@ function degradedForwardedPayload(
56
56
  value: request.value ?? "",
57
57
  matchedPattern: null,
58
58
  commandContext: null,
59
+ matchedSpelling: null,
59
60
  executedUnit: null,
60
61
  },
61
62
  // Nothing to carry: the wire no longer relays a sentence, and inventing
@@ -82,6 +82,7 @@ export function buildBashExternalDirectoryAskPayload(
82
82
  value: facts.command,
83
83
  matchedPattern: facts.matchedPattern ?? null,
84
84
  commandContext: null,
85
+ matchedSpelling: null,
85
86
  executedUnit: null,
86
87
  },
87
88
  evidence: [
@@ -113,6 +114,7 @@ function pathPayload(
113
114
  value: facts.pathValue,
114
115
  matchedPattern: facts.matchedPattern ?? null,
115
116
  commandContext: null,
117
+ matchedSpelling: null,
116
118
  executedUnit: null,
117
119
  },
118
120
  evidence,
@@ -63,6 +63,12 @@ export interface PromptRequestFacts {
63
63
  readonly value: string;
64
64
  /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
65
65
  readonly matchedPattern: string | null;
66
+ /**
67
+ * For bash, the spelling of the unit the matched rule matched when it did not
68
+ * match {@link value} as typed — the absolute spelling of a relative path
69
+ * argument, say. `null` when the typed text decided.
70
+ */
71
+ readonly matchedSpelling: string | null;
66
72
  /**
67
73
  * Where the offending bash unit runs, when it came from a substitution or a
68
74
  * subshell. A fact rather than a rendered clause: it is what makes the
@@ -172,6 +178,9 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
172
178
  candidate.commandContext,
173
179
  BASH_COMMAND_CONTEXTS,
174
180
  );
181
+ // Absent from a request a node built before the fact existed, which states
182
+ // no spelling rather than a malformed one.
183
+ const matchedSpelling = candidate.matchedSpelling ?? null;
175
184
  if (
176
185
  !requester ||
177
186
  commandContext === undefined ||
@@ -180,6 +189,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
180
189
  !isNullableString(candidate.toolName) ||
181
190
  !isNullableString(candidate.invokedToolName) ||
182
191
  !isNullableString(candidate.matchedPattern) ||
192
+ !isNullableString(matchedSpelling) ||
183
193
  !isNullableString(candidate.executedUnit)
184
194
  ) {
185
195
  return undefined;
@@ -192,6 +202,7 @@ function asPromptRequestFacts(value: unknown): PromptRequestFacts | undefined {
192
202
  invokedToolName: candidate.invokedToolName,
193
203
  value: candidate.value,
194
204
  matchedPattern: candidate.matchedPattern,
205
+ matchedSpelling,
195
206
  commandContext: commandContext.value,
196
207
  executedUnit: candidate.executedUnit,
197
208
  };
@@ -25,6 +25,7 @@ export function renderReviewLogFacts(
25
25
  return {
26
26
  surface: request.surface,
27
27
  ...present("matchedPattern", request.matchedPattern),
28
+ ...present("matchedSpelling", request.matchedSpelling),
28
29
  ...present("executedUnit", request.executedUnit),
29
30
  ...present("commandContext", request.commandContext),
30
31
  ...present("invokedToolName", request.invokedToolName),
@@ -42,6 +42,7 @@ function skillPayload(
42
42
  value: skillName,
43
43
  matchedPattern: null,
44
44
  commandContext: null,
45
+ matchedSpelling: null,
45
46
  executedUnit: null,
46
47
  },
47
48
  evidence,
@@ -44,6 +44,7 @@ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
44
44
  invokedToolName: distinctInvokedName(facts),
45
45
  value: askValue(check, bash, mcp),
46
46
  matchedPattern: check.matchedPattern ?? null,
47
+ matchedSpelling: check.matchedSpelling ?? null,
47
48
  commandContext: check.commandContext ?? null,
48
49
  executedUnit: check.executedUnit ?? null,
49
50
  },
@@ -1,5 +1,6 @@
1
- import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
1
+ import { BashProgram } from "#src/access-intent/bash/program";
2
2
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
3
+ import type { PathNormalizer } from "#src/path/path-normalizer";
3
4
  import type { ScopedPermissionResolver } from "#src/policy/permission-resolver";
4
5
  import type { PermissionCheckResult } from "#src/types";
5
6
 
@@ -14,7 +15,11 @@ import type { PermissionCheckResult } from "#src/types";
14
15
  * sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
15
16
  * (#840) — at parity with the gate.
16
17
  *
17
- * In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
18
+ * The command is parsed by `BashProgram.parseSync` over the session's
19
+ * `normalizer`, the same builder the gate's program comes from, so every slice
20
+ * the gate resolves a unit with is resolved here too.
21
+ *
22
+ * In the pre-warm window (`parseSync` returns `null`) it falls back
18
23
  * to the pre-#309 whole-string match, so the advisory answer is never *weaker*
19
24
  * than before — only strengthened once warm.
20
25
  *
@@ -25,9 +30,10 @@ export function resolveBashAdvisoryCheck(
25
30
  command: string,
26
31
  agentName: string | undefined,
27
32
  resolver: ScopedPermissionResolver,
33
+ normalizer: PathNormalizer,
28
34
  ): PermissionCheckResult {
29
- const commands = parseBashCommandsSync(command);
30
- if (commands === null) {
35
+ const program = BashProgram.parseSync(command, normalizer);
36
+ if (program === null) {
31
37
  return resolver.resolve({
32
38
  kind: "tool",
33
39
  surface: "bash",
@@ -35,5 +41,10 @@ export function resolveBashAdvisoryCheck(
35
41
  agentName,
36
42
  });
37
43
  }
38
- return resolveBashCommandCheck(command, commands, agentName, resolver);
44
+ return resolveBashCommandCheck(
45
+ command,
46
+ program.commands(),
47
+ agentName,
48
+ resolver,
49
+ );
39
50
  }
@@ -67,7 +67,12 @@ export class LocalPermissionsService implements PermissionsService {
67
67
  // the enforcement gate enforces (#309). A cold parser falls back to the
68
68
  // whole-string match inside resolveBashAdvisoryCheck.
69
69
  if (surface === "bash") {
70
- return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver);
70
+ return resolveBashAdvisoryCheck(
71
+ value ?? "",
72
+ agentName,
73
+ this.resolver,
74
+ this.session.getPathNormalizer(),
75
+ );
71
76
  }
72
77
  const intent = buildAccessIntentForSurface(
73
78
  surface,
package/src/types.ts CHANGED
@@ -83,6 +83,13 @@ export interface PermissionCheckResult {
83
83
  * {@link floorExemption} says the wrapper's floor has no reason to hold.
84
84
  */
85
85
  executedUnit?: string;
86
+ /**
87
+ * The spelling of the bash unit the winning rule matched, when the rule did
88
+ * not also match the unit as typed — the absolute spelling of a relative
89
+ * path argument, say (#910). Display-only: absent whenever the typed text
90
+ * decided, and dropped by a floor that replaces {@link matchedPattern}.
91
+ */
92
+ matchedSpelling?: string;
86
93
  /**
87
94
  * Set when the winning bash unit is a wrapper the floor no longer covers,
88
95
  * naming why (#803, #963). Recorded in the review log so an allow the floor would
@@ -96,6 +103,22 @@ export interface PermissionCheckResult {
96
103
  * node to clamp its own `allow` with.
97
104
  */
98
105
  floor?: string;
106
+ /**
107
+ * Every unit of the bash chain that resolved to `ask` and was not
108
+ * session-granted, in chain order, each with its own floor. Set only on an
109
+ * asking chain winner: the serving node of a forwarded ask judges each one,
110
+ * since the winner alone would approve commands it never saw.
111
+ */
112
+ askingUnits?: readonly AskingBashUnit[];
113
+ }
114
+
115
+ /**
116
+ * One unit of a bash chain the gate left asking, as the serving node of a
117
+ * forwarded ask needs it: the unit as typed, and the floor that raised it.
118
+ */
119
+ export interface AskingBashUnit {
120
+ command: string;
121
+ floor?: string;
99
122
  }
100
123
 
101
124
  export function isPermissionState(value: unknown): value is PermissionState {