@gotgenes/pi-permission-system 39.1.0 → 39.1.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 CHANGED
@@ -5,6 +5,17 @@ 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
+ ## [39.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.0...pi-permission-system-v39.1.1) (2026-10-06)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** a subagent's floored bash ask prompts on the parent instead of riding its allow rule ([b33d6df](https://github.com/gotgenes/pi-packages/commit/b33d6df6e7bf430d5df898e190394c2d5d3ac9b7)), closes [#1029](https://github.com/gotgenes/pi-packages/issues/1029)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** a forwarded ask keeps its floor ([0db97e4](https://github.com/gotgenes/pi-packages/commit/0db97e48bf6deea75bba36212e62d3c12502c43c)), closes [#1029](https://github.com/gotgenes/pi-packages/issues/1029)
18
+
8
19
  ## [39.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.4...pi-permission-system-v39.1.0) (2026-10-05)
9
20
 
10
21
 
package/dist/public.d.ts CHANGED
@@ -69,6 +69,13 @@ interface PermissionCheckResult {
69
69
  * once have prompted for is auditable to the reason that let it through.
70
70
  */
71
71
  floorExemption?: FloorExemption;
72
+ /**
73
+ * The synthetic pattern of a floor that raised this ask on the bash chain —
74
+ * on the deciding unit, or on another asking unit of the same command. Only
75
+ * the child's parse knows it, so a forwarded ask carries it for the serving
76
+ * node to clamp its own `allow` with.
77
+ */
78
+ floor?: string;
72
79
  }
73
80
 
74
81
  /**
@@ -370,6 +377,14 @@ interface ForwardedAccessFacts {
370
377
  matchValues: string[];
371
378
  /** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
372
379
  boundaryValue: string | null;
380
+ /**
381
+ * The synthetic pattern of a floor that raised a bash ask on the child
382
+ * (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
383
+ * `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
384
+ * parse knows it, so the serving node clamps its own `allow` with it rather
385
+ * than recomputing it. Absent when no floor was raised.
386
+ */
387
+ floor?: string;
373
388
  }
374
389
 
375
390
  type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -538,6 +538,10 @@ Every synthetic `ask` above — the two parse sentinels and both wrapper floors
538
538
  An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
539
539
  Approving one for the session works normally: the floors clamp the decision and leave the grant's provenance intact, so a command you have already approved does not prompt again.
540
540
 
541
+ A subagent's floored ask keeps its floor when it is forwarded to the parent session.
542
+ The parent's `allow` rule does not answer it, so `sudo rm x` from a subagent prompts in the parent even under a parent `bash: *` allow.
543
+ The parent's `deny` still denies, and a grant for the whole parent session or the parent's `yoloMode` still approves.
544
+
541
545
  Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
542
546
  A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
543
547
  To gate bash commands, add `"bash": { "*": "ask" }` (or `"deny"`).
@@ -148,6 +148,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
148
148
  When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
149
149
  The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
150
150
  A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
151
+ The exception is a bash ask the child raised through a [synthetic floor](configuration.md#fail-closed-behavior) (a wrapper such as `sudo` or `bash -c`, or a command the parser could not resolve): the child forwards the floor with the ask, and a parent `allow` rule escalates it to the parent's prompt instead of approving it.
151
152
 
152
153
  This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
153
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.1.0",
3
+ "version": "39.1.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,10 +56,13 @@ export interface InboxProcessor {
56
56
  * (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
57
57
  * used as-is, never re-derived through this session's `PathNormalizer`/cwd.
58
58
  *
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.
61
+ *
59
62
  * Narrow by design (ISP): the server needs one decision, not the whole
60
- * resolver. The composition root satisfies it with
61
- * `buildResolvedIntentFromMatchValues` plus `resolver.resolve`, the same
62
- * `resolve` entry point `LocalPermissionsService` composes.
63
+ * resolver. The composition root satisfies it with `ResolverServingPolicy`
64
+ * (`src/policy/serving-policy.ts`), over the same `resolve` entry point
65
+ * `LocalPermissionsService` composes.
63
66
  */
64
67
  export interface ServingPolicy {
65
68
  resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
@@ -151,6 +154,7 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
151
154
  surface: intent.surface,
152
155
  matchValues: intent.matchValues,
153
156
  boundaryValue: intent.boundaryValue,
157
+ ...(intent.floor === undefined ? {} : { floor: intent.floor }),
154
158
  };
155
159
  }
156
160
 
@@ -465,7 +469,13 @@ export class ForwardedRequestServer implements InboxProcessor {
465
469
  : { ...createDeniedPermissionDecision(check.reason), decidedBy };
466
470
  }
467
471
 
468
- this.logger.review("forwarded_permission.prompted", logDetails);
472
+ // The child's floor is why an ask this node's `allow` would once have
473
+ // answered reached a human instead, so the entry says so.
474
+ const floor = request.accessIntent?.floor;
475
+ this.logger.review(
476
+ "forwarded_permission.prompted",
477
+ floor === undefined ? logDetails : { ...logDetails, floor },
478
+ );
469
479
  const details = buildForwardedAskDetails(request);
470
480
  const decision = await this.escalateAsk(details);
471
481
  // Announced before the grant-scope translation and before the response is
@@ -124,6 +124,7 @@ function asForwardedAccessIntent(
124
124
  surface?: unknown;
125
125
  matchValues?: unknown;
126
126
  boundaryValue?: unknown;
127
+ floor?: unknown;
127
128
  requesterCwd?: unknown;
128
129
  principal?: unknown;
129
130
  };
@@ -135,6 +136,9 @@ function asForwardedAccessIntent(
135
136
  candidate.boundaryValue === null ||
136
137
  typeof candidate.boundaryValue === "string"
137
138
  ) ||
139
+ // A malformed floor drops the whole intent, which escalates: it must
140
+ // never read as "no floor".
141
+ !(candidate.floor === undefined || typeof candidate.floor === "string") ||
138
142
  typeof candidate.requesterCwd !== "string" ||
139
143
  typeof candidate.principal !== "object" ||
140
144
  candidate.principal === null
@@ -155,6 +159,7 @@ function asForwardedAccessIntent(
155
159
  surface: candidate.surface,
156
160
  matchValues: [...candidate.matchValues],
157
161
  boundaryValue: candidate.boundaryValue,
162
+ ...(candidate.floor === undefined ? {} : { floor: candidate.floor }),
158
163
  requesterCwd: candidate.requesterCwd,
159
164
  principal: {
160
165
  sessionId: principal.sessionId,
@@ -123,6 +123,14 @@ export interface ForwardedAccessFacts {
123
123
  matchValues: string[];
124
124
  /** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
125
125
  boundaryValue: string | null;
126
+ /**
127
+ * The synthetic pattern of a floor that raised a bash ask on the child
128
+ * (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
129
+ * `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
130
+ * parse knows it, so the serving node clamps its own `allow` with it rather
131
+ * than recomputing it. Absent when no floor was raised.
132
+ */
133
+ floor?: string;
126
134
  }
127
135
 
128
136
  /**
@@ -68,6 +68,12 @@ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
68
68
  */
69
69
  const UNPARSED_SUBTREE_SENTINEL = "<unparsed-bash-subtree>";
70
70
 
71
+ /**
72
+ * The synthetic `matchedPattern` recorded when a command the parse matched
73
+ * nothing in is floored to `ask` (#452).
74
+ */
75
+ const UNPARSEABLE_COMMAND_SENTINEL = "<unparseable-bash-command>";
76
+
71
77
  export function resolveBashCommandCheck(
72
78
  command: string,
73
79
  commands: BashCommand[],
@@ -97,7 +103,8 @@ export function resolveBashCommandCheck(
97
103
  source: "bash",
98
104
  origin: "builtin",
99
105
  command,
100
- matchedPattern: "<unparseable-bash-command>",
106
+ matchedPattern: UNPARSEABLE_COMMAND_SENTINEL,
107
+ floor: UNPARSEABLE_COMMAND_SENTINEL,
101
108
  };
102
109
  }
103
110
  }
@@ -105,10 +112,32 @@ export function resolveBashCommandCheck(
105
112
  const results = commands.map((cmd) =>
106
113
  resolveCommandUnit(cmd, command, agentName, resolver),
107
114
  );
108
- return (
115
+ const winner =
109
116
  pickMostRestrictive(results) ??
110
- resolveOnBashSurface(command, [], agentName, resolver)
117
+ resolveOnBashSurface(command, [], agentName, resolver);
118
+ return withChainFloor(winner, results);
119
+ }
120
+
121
+ /**
122
+ * Carry onto an asking winner the floor another asking unit of the chain
123
+ * raised.
124
+ *
125
+ * The winner is the first of the most restrictive units, so in
126
+ * `git push x && sudo rm y` a `git push *: ask` rule wins the tie and the
127
+ * wrapper's floor would otherwise go unrecorded. A forwarded ask is judged on
128
+ * the winner's value alone, so the floor has to ride the result for the
129
+ * serving node to keep it (#1029). A unit the session already granted is left
130
+ * out: the user approved exactly that command.
131
+ */
132
+ function withChainFloor(
133
+ winner: PermissionCheckResult,
134
+ results: readonly PermissionCheckResult[],
135
+ ): PermissionCheckResult {
136
+ if (winner.state !== "ask" || winner.floor !== undefined) return winner;
137
+ const floored = results.find(
138
+ (result) => result.floor !== undefined && result.source !== "session",
111
139
  );
140
+ return floored ? { ...winner, floor: floored.floor } : winner;
112
141
  }
113
142
 
114
143
  /**
@@ -168,11 +197,25 @@ function floorUnparsedUnit(
168
197
  resolved: PermissionCheckResult,
169
198
  ): PermissionCheckResult {
170
199
  if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
200
+ return { ...floorToAsk(resolved, UNPARSED_SUBTREE_SENTINEL), command };
201
+ }
202
+
203
+ /**
204
+ * Clamp a resolved check up to a synthetic `ask` naming the floor that raised
205
+ * it.
206
+ *
207
+ * Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
208
+ * session fast path.
209
+ */
210
+ function floorToAsk(
211
+ resolved: PermissionCheckResult,
212
+ sentinel: string,
213
+ ): PermissionCheckResult {
171
214
  return {
172
215
  ...resolved,
173
216
  state: "ask",
174
- command,
175
- matchedPattern: UNPARSED_SUBTREE_SENTINEL,
217
+ matchedPattern: sentinel,
218
+ floor: sentinel,
176
219
  };
177
220
  }
178
221
 
@@ -199,11 +242,7 @@ function resolveWrapperUnit(
199
242
  ): PermissionCheckResult {
200
243
  const inner = cmd.floorExemption && cmd.executedUnit;
201
244
  if (!inner) {
202
- return {
203
- ...base,
204
- state: "ask",
205
- matchedPattern: WRAPPER_SENTINEL[wrapperKind],
206
- };
245
+ return floorToAsk(base, WRAPPER_SENTINEL[wrapperKind]);
207
246
  }
208
247
  // The inner command's rule decides, but the unit is still what runs: the
209
248
  // prompt, the decision value, and the session-approval suggestion all read
@@ -173,10 +173,14 @@ export function describeToolGate(
173
173
  );
174
174
 
175
175
  // A path-bearing tool carries the AccessPath's alias set; every other surface
176
- // (bash command, MCP target, plain tool) carries its already-portable value.
176
+ // (bash command, MCP target, plain tool) carries its already-portable value,
177
+ // and a bash ask the floor that raised it.
177
178
  const accessIntent = pathAccess
178
179
  ? accessFactsFromPath(gateSurface, pathAccess.path)
179
- : accessFactsFromValue(gateSurface, decisionValue);
180
+ : {
181
+ ...accessFactsFromValue(gateSurface, decisionValue),
182
+ ...floorFact(check),
183
+ };
180
184
 
181
185
  return {
182
186
  surface: gateSurface,
@@ -226,3 +230,14 @@ function floorExemptionFact(
226
230
  ? {}
227
231
  : { floorExemption: check.floorExemption };
228
232
  }
233
+
234
+ /**
235
+ * The floor that raised a bash ask, as a child-fixed fact for the wire.
236
+ *
237
+ * Only the child's parse knows a unit was a wrapper or failed to parse, so the
238
+ * serving node of a forwarded ask cannot recompute it (ADR 0008). Absent when
239
+ * no floor was raised, so every other ask's facts keep their exact shape.
240
+ */
241
+ function floorFact(check: PermissionCheckResult): { floor?: string } {
242
+ return check.floor === undefined ? {} : { floor: check.floor };
243
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { warmBashParser } from "#src/access-intent/bash/parser";
4
- import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
5
4
  import { AskDialogQueue } from "#src/authority/ask-dialog-queue";
6
5
  import { AuthorizerChainAudit } from "#src/authority/authorizer-chain-audit";
7
6
  import {
@@ -10,10 +9,7 @@ import {
10
9
  } from "#src/authority/authorizer-registry";
11
10
  import { AuthorizerSelection } from "#src/authority/authorizer-selection";
12
11
  import { ChildNodeAudit } from "#src/authority/child-node-audit";
13
- import {
14
- ForwardedRequestServer,
15
- type ServingPolicy,
16
- } from "#src/authority/forwarded-request-server";
12
+ import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
17
13
  import {
18
14
  ForwardingLivenessJudge,
19
15
  ServingHeartbeatStore,
@@ -52,6 +48,7 @@ import { PermissionSessionLogger } from "#src/logging/session-logger";
52
48
  import { pathFlavorForPlatform } from "#src/path/path-flavor";
53
49
  import { PermissionManager } from "#src/policy/permission-manager";
54
50
  import { PermissionResolver } from "#src/policy/permission-resolver";
51
+ import { ResolverServingPolicy } from "#src/policy/serving-policy";
55
52
  import { resolveRenderBudget } from "#src/presentation/dialog-renderer";
56
53
  import { LocalPermissionsService } from "#src/service/permissions-service";
57
54
  import { PermissionServiceLifecycle } from "#src/service/service-lifecycle";
@@ -198,16 +195,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
198
195
  // composed ruleset, agent-scoped to the requester (§3) — the match values
199
196
  // are used as fixed by the child, never re-derived through this session's
200
197
  // PathNormalizer/cwd (#597).
201
- const servingPolicy: ServingPolicy = {
202
- resolve: (intent) =>
203
- resolver.resolve(
204
- buildResolvedIntentFromMatchValues(
205
- intent.surface,
206
- intent.matchValues,
207
- intent.principal.agentName,
208
- ),
209
- ),
210
- };
198
+ const servingPolicy = new ResolverServingPolicy(resolver, isYoloEnabled);
211
199
 
212
200
  // Constructed here rather than beside the gate runner below: the serving
213
201
  // side broadcasts its own decisions, so both readers share one reporter over
@@ -0,0 +1,63 @@
1
+ import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
2
+ import type { ServingPolicy } from "#src/authority/forwarded-request-server";
3
+ import type { ForwardedAccessIntent } from "#src/authority/permission-forwarding";
4
+ import type { PermissionCheckResult } from "#src/types";
5
+ import type { PermissionResolver } from "./permission-resolver";
6
+
7
+ /**
8
+ * The serving node's recorded authority over a forwarded request: the
9
+ * child-fixed facts (ADR 0008) resolved against this node's composed ruleset,
10
+ * agent-scoped to the requester (`principal.agentName`, §3), with the floor the
11
+ * child raised honored.
12
+ *
13
+ * The `matchValues` are used as the child fixed them, never re-derived through
14
+ * this session's `PathNormalizer`/cwd. The resolver's surface-family fold is
15
+ * what keeps a parent's bare `path` deny answering a child's request, which is
16
+ * why this goes through `resolve` rather than the manager directly (#712, #806).
17
+ */
18
+ export class ResolverServingPolicy implements ServingPolicy {
19
+ constructor(
20
+ private readonly resolver: Pick<PermissionResolver, "resolve">,
21
+ private readonly isYoloEnabled: () => boolean,
22
+ ) {}
23
+
24
+ resolve(intent: ForwardedAccessIntent): PermissionCheckResult {
25
+ const check = this.resolver.resolve(
26
+ buildResolvedIntentFromMatchValues(
27
+ intent.surface,
28
+ intent.matchValues,
29
+ intent.principal.agentName,
30
+ ),
31
+ );
32
+ return honorChildFloor(check, intent.floor, this.isYoloEnabled());
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Clamp this node's `allow` with the floor the child raised, as the child's
38
+ * own gate clamped its own (#1029).
39
+ *
40
+ * Only the child's parse knows a bash unit was a wrapper or failed to parse,
41
+ * so without this a permissive serving rule answers exactly the ask the floor
42
+ * exists to raise. The floor leaves three answers standing, each for the
43
+ * reason it stands locally: a `deny` or `ask` decides before any floor is
44
+ * consulted; a session grant names a command the user already approved, which
45
+ * the local gate honors through its session fast path; and yolo approves every
46
+ * synthetic ask, keeping the floor's sentinel as the reported pattern.
47
+ */
48
+ function honorChildFloor(
49
+ check: PermissionCheckResult,
50
+ floor: string | undefined,
51
+ yoloEnabled: boolean,
52
+ ): PermissionCheckResult {
53
+ if (
54
+ floor === undefined ||
55
+ check.state !== "allow" ||
56
+ check.source === "session"
57
+ ) {
58
+ return check;
59
+ }
60
+ return yoloEnabled
61
+ ? { ...check, origin: "yolo", matchedPattern: floor }
62
+ : { ...check, state: "ask", matchedPattern: floor };
63
+ }
package/src/types.ts CHANGED
@@ -89,6 +89,13 @@ export interface PermissionCheckResult {
89
89
  * once have prompted for is auditable to the reason that let it through.
90
90
  */
91
91
  floorExemption?: FloorExemption;
92
+ /**
93
+ * The synthetic pattern of a floor that raised this ask on the bash chain —
94
+ * on the deciding unit, or on another asking unit of the same command. Only
95
+ * the child's parse knows it, so a forwarded ask carries it for the serving
96
+ * node to clamp its own `allow` with.
97
+ */
98
+ floor?: string;
92
99
  }
93
100
 
94
101
  export function isPermissionState(value: unknown): value is PermissionState {