@gotgenes/pi-permission-system 39.1.0 → 39.1.2
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 +22 -0
- package/dist/public.d.ts +38 -0
- package/docs/configuration.md +5 -0
- package/docs/subagent-integration.md +2 -0
- package/package.json +1 -1
- package/src/authority/forwarded-request-server.ts +19 -4
- package/src/authority/forwarding-io.ts +49 -0
- package/src/authority/permission-forwarding.ts +17 -0
- package/src/handlers/gates/bash-command.ts +72 -10
- package/src/handlers/gates/tool.ts +32 -3
- package/src/index.ts +3 -15
- package/src/policy/serving-policy.ts +102 -0
- package/src/types.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ 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.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.1...pi-permission-system-v39.1.2) (2026-10-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a subagent's forwarded bash line is judged command by command on the parent ([0990e92](https://github.com/gotgenes/pi-packages/commit/0990e921868d077868b007682ffbaacc3a00cbc8)), closes [#1030](https://github.com/gotgenes/pi-packages/issues/1030)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** a forwarded bash line carries every asking command ([5cb6696](https://github.com/gotgenes/pi-packages/commit/5cb6696feb8572247dd6d09d4eb980292c0510f1))
|
|
18
|
+
|
|
19
|
+
## [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)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **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)
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* **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)
|
|
29
|
+
|
|
8
30
|
## [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
31
|
|
|
10
32
|
|
package/dist/public.d.ts
CHANGED
|
@@ -69,6 +69,28 @@ 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;
|
|
79
|
+
/**
|
|
80
|
+
* Every unit of the bash chain that resolved to `ask` and was not
|
|
81
|
+
* session-granted, in chain order, each with its own floor. Set only on an
|
|
82
|
+
* asking chain winner: the serving node of a forwarded ask judges each one,
|
|
83
|
+
* since the winner alone would approve commands it never saw.
|
|
84
|
+
*/
|
|
85
|
+
askingUnits?: readonly AskingBashUnit[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One unit of a bash chain the gate left asking, as the serving node of a
|
|
89
|
+
* forwarded ask needs it: the unit as typed, and the floor that raised it.
|
|
90
|
+
*/
|
|
91
|
+
interface AskingBashUnit {
|
|
92
|
+
command: string;
|
|
93
|
+
floor?: string;
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
/**
|
|
@@ -370,6 +392,22 @@ interface ForwardedAccessFacts {
|
|
|
370
392
|
matchValues: string[];
|
|
371
393
|
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
372
394
|
boundaryValue: string | null;
|
|
395
|
+
/**
|
|
396
|
+
* The synthetic pattern of a floor that raised a bash ask on the child
|
|
397
|
+
* (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
|
|
398
|
+
* `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
|
|
399
|
+
* parse knows it, so the serving node clamps its own `allow` with it rather
|
|
400
|
+
* than recomputing it. Absent when no floor was raised.
|
|
401
|
+
*/
|
|
402
|
+
floor?: string;
|
|
403
|
+
/**
|
|
404
|
+
* Every unit of a bash chain the child left asking, each with its own floor.
|
|
405
|
+
* `matchValues` names only the chain's winner, and the serving node's answer
|
|
406
|
+
* approves the whole tool call, so it judges each of these instead. Absent
|
|
407
|
+
* for a non-bash ask and for a bash ask raised without a chain (the empty
|
|
408
|
+
* parse), where `matchValues` is the whole command.
|
|
409
|
+
*/
|
|
410
|
+
askingUnits?: AskingBashUnit[];
|
|
373
411
|
}
|
|
374
412
|
|
|
375
413
|
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
package/docs/configuration.md
CHANGED
|
@@ -538,6 +538,11 @@ 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
|
+
A subagent's chained command (`ls && rm -rf /tmp/x`) is judged by the parent command by command: every command the subagent's own policy left asking is forwarded with its floor, and the parent's `deny` or `ask` on any one of them decides, not only on the first.
|
|
545
|
+
|
|
541
546
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
542
547
|
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
548
|
To gate bash commands, add `"bash": { "*": "ask" }` (or `"deny"`).
|
|
@@ -148,6 +148,8 @@ 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
|
+
For a chained bash command, the parent judges every command the child left asking, not only the first, and the most restrictive answer decides.
|
|
152
|
+
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
153
|
|
|
152
154
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
|
153
155
|
|
package/package.json
CHANGED
|
@@ -56,10 +56,15 @@ 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. 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.
|
|
63
|
+
*
|
|
59
64
|
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
60
|
-
* resolver. The composition root satisfies it with
|
|
61
|
-
* `
|
|
62
|
-
* `
|
|
65
|
+
* resolver. The composition root satisfies it with `ResolverServingPolicy`
|
|
66
|
+
* (`src/policy/serving-policy.ts`), over the same `resolve` entry point
|
|
67
|
+
* `LocalPermissionsService` composes.
|
|
63
68
|
*/
|
|
64
69
|
export interface ServingPolicy {
|
|
65
70
|
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
|
@@ -151,6 +156,10 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
|
151
156
|
surface: intent.surface,
|
|
152
157
|
matchValues: intent.matchValues,
|
|
153
158
|
boundaryValue: intent.boundaryValue,
|
|
159
|
+
...(intent.floor === undefined ? {} : { floor: intent.floor }),
|
|
160
|
+
...(intent.askingUnits === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { askingUnits: intent.askingUnits }),
|
|
154
163
|
};
|
|
155
164
|
}
|
|
156
165
|
|
|
@@ -465,7 +474,13 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
465
474
|
: { ...createDeniedPermissionDecision(check.reason), decidedBy };
|
|
466
475
|
}
|
|
467
476
|
|
|
468
|
-
this
|
|
477
|
+
// The child's floor is why an ask this node's `allow` would once have
|
|
478
|
+
// answered reached a human instead, so the entry says so.
|
|
479
|
+
const floor = request.accessIntent?.floor;
|
|
480
|
+
this.logger.review(
|
|
481
|
+
"forwarded_permission.prompted",
|
|
482
|
+
floor === undefined ? logDetails : { ...logDetails, floor },
|
|
483
|
+
);
|
|
469
484
|
const details = buildForwardedAskDetails(request);
|
|
470
485
|
const decision = await this.escalateAsk(details);
|
|
471
486
|
// Announced before the grant-scope translation and before the response is
|
|
@@ -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 {
|
|
@@ -124,6 +125,8 @@ function asForwardedAccessIntent(
|
|
|
124
125
|
surface?: unknown;
|
|
125
126
|
matchValues?: unknown;
|
|
126
127
|
boundaryValue?: unknown;
|
|
128
|
+
floor?: unknown;
|
|
129
|
+
askingUnits?: unknown;
|
|
127
130
|
requesterCwd?: unknown;
|
|
128
131
|
principal?: unknown;
|
|
129
132
|
};
|
|
@@ -135,6 +138,15 @@ function asForwardedAccessIntent(
|
|
|
135
138
|
candidate.boundaryValue === null ||
|
|
136
139
|
typeof candidate.boundaryValue === "string"
|
|
137
140
|
) ||
|
|
141
|
+
// A malformed floor drops the whole intent, which escalates: it must
|
|
142
|
+
// never read as "no floor".
|
|
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
|
+
) ||
|
|
138
150
|
typeof candidate.requesterCwd !== "string" ||
|
|
139
151
|
typeof candidate.principal !== "object" ||
|
|
140
152
|
candidate.principal === null
|
|
@@ -155,6 +167,10 @@ function asForwardedAccessIntent(
|
|
|
155
167
|
surface: candidate.surface,
|
|
156
168
|
matchValues: [...candidate.matchValues],
|
|
157
169
|
boundaryValue: candidate.boundaryValue,
|
|
170
|
+
...(candidate.floor === undefined ? {} : { floor: candidate.floor }),
|
|
171
|
+
...(isWellFormedAskingUnits(candidate.askingUnits)
|
|
172
|
+
? { askingUnits: copyAskingUnits(candidate.askingUnits) }
|
|
173
|
+
: {}),
|
|
158
174
|
requesterCwd: candidate.requesterCwd,
|
|
159
175
|
principal: {
|
|
160
176
|
sessionId: principal.sessionId,
|
|
@@ -163,6 +179,39 @@ function asForwardedAccessIntent(
|
|
|
163
179
|
};
|
|
164
180
|
}
|
|
165
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
|
+
|
|
166
215
|
export function formatUnknownErrorMessage(error: unknown): string {
|
|
167
216
|
if (error instanceof Error && error.message) {
|
|
168
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";
|
|
@@ -123,6 +124,22 @@ export interface ForwardedAccessFacts {
|
|
|
123
124
|
matchValues: string[];
|
|
124
125
|
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
125
126
|
boundaryValue: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* The synthetic pattern of a floor that raised a bash ask on the child
|
|
129
|
+
* (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
|
|
130
|
+
* `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
|
|
131
|
+
* parse knows it, so the serving node clamps its own `allow` with it rather
|
|
132
|
+
* than recomputing it. Absent when no floor was raised.
|
|
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[];
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
/**
|
|
@@ -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:
|
|
106
|
+
matchedPattern: UNPARSEABLE_COMMAND_SENTINEL,
|
|
107
|
+
floor: UNPARSEABLE_COMMAND_SENTINEL,
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
}
|
|
@@ -105,10 +112,55 @@ export function resolveBashCommandCheck(
|
|
|
105
112
|
const results = commands.map((cmd) =>
|
|
106
113
|
resolveCommandUnit(cmd, command, agentName, resolver),
|
|
107
114
|
);
|
|
108
|
-
|
|
115
|
+
const winner =
|
|
109
116
|
pickMostRestrictive(results) ??
|
|
110
|
-
resolveOnBashSurface(command, [], agentName, resolver)
|
|
117
|
+
resolveOnBashSurface(command, [], agentName, resolver);
|
|
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 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Carry onto an asking winner the floor another asking unit of the chain
|
|
146
|
+
* raised.
|
|
147
|
+
*
|
|
148
|
+
* The winner is the first of the most restrictive units, so in
|
|
149
|
+
* `git push x && sudo rm y` a `git push *: ask` rule wins the tie and the
|
|
150
|
+
* wrapper's floor would otherwise go unrecorded. A forwarded ask is judged on
|
|
151
|
+
* the winner's value alone, so the floor has to ride the result for the
|
|
152
|
+
* serving node to keep it (#1029). A unit the session already granted is left
|
|
153
|
+
* out: the user approved exactly that command.
|
|
154
|
+
*/
|
|
155
|
+
function withChainFloor(
|
|
156
|
+
winner: PermissionCheckResult,
|
|
157
|
+
results: readonly PermissionCheckResult[],
|
|
158
|
+
): PermissionCheckResult {
|
|
159
|
+
if (winner.state !== "ask" || winner.floor !== undefined) return winner;
|
|
160
|
+
const floored = results.find(
|
|
161
|
+
(result) => result.floor !== undefined && result.source !== "session",
|
|
111
162
|
);
|
|
163
|
+
return floored ? { ...winner, floor: floored.floor } : winner;
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
/**
|
|
@@ -168,11 +220,25 @@ function floorUnparsedUnit(
|
|
|
168
220
|
resolved: PermissionCheckResult,
|
|
169
221
|
): PermissionCheckResult {
|
|
170
222
|
if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
|
|
223
|
+
return { ...floorToAsk(resolved, UNPARSED_SUBTREE_SENTINEL), command };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Clamp a resolved check up to a synthetic `ask` naming the floor that raised
|
|
228
|
+
* it.
|
|
229
|
+
*
|
|
230
|
+
* Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
|
|
231
|
+
* session fast path.
|
|
232
|
+
*/
|
|
233
|
+
function floorToAsk(
|
|
234
|
+
resolved: PermissionCheckResult,
|
|
235
|
+
sentinel: string,
|
|
236
|
+
): PermissionCheckResult {
|
|
171
237
|
return {
|
|
172
238
|
...resolved,
|
|
173
239
|
state: "ask",
|
|
174
|
-
|
|
175
|
-
|
|
240
|
+
matchedPattern: sentinel,
|
|
241
|
+
floor: sentinel,
|
|
176
242
|
};
|
|
177
243
|
}
|
|
178
244
|
|
|
@@ -199,11 +265,7 @@ function resolveWrapperUnit(
|
|
|
199
265
|
): PermissionCheckResult {
|
|
200
266
|
const inner = cmd.floorExemption && cmd.executedUnit;
|
|
201
267
|
if (!inner) {
|
|
202
|
-
return
|
|
203
|
-
...base,
|
|
204
|
-
state: "ask",
|
|
205
|
-
matchedPattern: WRAPPER_SENTINEL[wrapperKind],
|
|
206
|
-
};
|
|
268
|
+
return floorToAsk(base, WRAPPER_SENTINEL[wrapperKind]);
|
|
207
269
|
}
|
|
208
270
|
// The inner command's rule decides, but the unit is still what runs: the
|
|
209
271
|
// prompt, the decision value, and the session-approval suggestion all read
|
|
@@ -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,
|
|
@@ -173,10 +173,15 @@ 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
|
-
:
|
|
180
|
+
: {
|
|
181
|
+
...accessFactsFromValue(gateSurface, decisionValue),
|
|
182
|
+
...floorFact(check),
|
|
183
|
+
...askingUnitsFact(check),
|
|
184
|
+
};
|
|
180
185
|
|
|
181
186
|
return {
|
|
182
187
|
surface: gateSurface,
|
|
@@ -226,3 +231,27 @@ function floorExemptionFact(
|
|
|
226
231
|
? {}
|
|
227
232
|
: { floorExemption: check.floorExemption };
|
|
228
233
|
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The floor that raised a bash ask, as a child-fixed fact for the wire.
|
|
237
|
+
*
|
|
238
|
+
* Only the child's parse knows a unit was a wrapper or failed to parse, so the
|
|
239
|
+
* serving node of a forwarded ask cannot recompute it (ADR 0008). Absent when
|
|
240
|
+
* no floor was raised, so every other ask's facts keep their exact shape.
|
|
241
|
+
*/
|
|
242
|
+
function floorFact(check: PermissionCheckResult): { floor?: string } {
|
|
243
|
+
return check.floor === undefined ? {} : { floor: check.floor };
|
|
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
|
+
}
|
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
|
|
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,102 @@
|
|
|
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 { AskingBashUnit, PermissionCheckResult } from "#src/types";
|
|
5
|
+
import type { PermissionResolver } from "./permission-resolver";
|
|
6
|
+
import { mostRestrictiveOf } from "./restrictiveness";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The serving node's recorded authority over a forwarded request: the
|
|
10
|
+
* child-fixed facts (ADR 0008) resolved against this node's composed ruleset,
|
|
11
|
+
* agent-scoped to the requester (`principal.agentName`, §3), with the floor the
|
|
12
|
+
* child raised honored.
|
|
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
|
+
*
|
|
20
|
+
* The `matchValues` are used as the child fixed them, never re-derived through
|
|
21
|
+
* this session's `PathNormalizer`/cwd. The resolver's surface-family fold is
|
|
22
|
+
* what keeps a parent's bare `path` deny answering a child's request, which is
|
|
23
|
+
* why this goes through `resolve` rather than the manager directly (#712, #806).
|
|
24
|
+
*/
|
|
25
|
+
export class ResolverServingPolicy implements ServingPolicy {
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly resolver: Pick<PermissionResolver, "resolve">,
|
|
28
|
+
private readonly isYoloEnabled: () => boolean,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
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 {
|
|
64
|
+
const check = this.resolver.resolve(
|
|
65
|
+
buildResolvedIntentFromMatchValues(
|
|
66
|
+
intent.surface,
|
|
67
|
+
intent.matchValues,
|
|
68
|
+
intent.principal.agentName,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
return honorChildFloor(check, intent.floor, this.isYoloEnabled());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clamp this node's `allow` with the floor the child raised, as the child's
|
|
77
|
+
* own gate clamped its own (#1029).
|
|
78
|
+
*
|
|
79
|
+
* Only the child's parse knows a bash unit was a wrapper or failed to parse,
|
|
80
|
+
* so without this a permissive serving rule answers exactly the ask the floor
|
|
81
|
+
* exists to raise. The floor leaves three answers standing, each for the
|
|
82
|
+
* reason it stands locally: a `deny` or `ask` decides before any floor is
|
|
83
|
+
* consulted; a session grant names a command the user already approved, which
|
|
84
|
+
* the local gate honors through its session fast path; and yolo approves every
|
|
85
|
+
* synthetic ask, keeping the floor's sentinel as the reported pattern.
|
|
86
|
+
*/
|
|
87
|
+
function honorChildFloor(
|
|
88
|
+
check: PermissionCheckResult,
|
|
89
|
+
floor: string | undefined,
|
|
90
|
+
yoloEnabled: boolean,
|
|
91
|
+
): PermissionCheckResult {
|
|
92
|
+
if (
|
|
93
|
+
floor === undefined ||
|
|
94
|
+
check.state !== "allow" ||
|
|
95
|
+
check.source === "session"
|
|
96
|
+
) {
|
|
97
|
+
return check;
|
|
98
|
+
}
|
|
99
|
+
return yoloEnabled
|
|
100
|
+
? { ...check, origin: "yolo", matchedPattern: floor }
|
|
101
|
+
: { ...check, state: "ask", matchedPattern: floor };
|
|
102
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -89,6 +89,29 @@ 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;
|
|
99
|
+
/**
|
|
100
|
+
* Every unit of the bash chain that resolved to `ask` and was not
|
|
101
|
+
* session-granted, in chain order, each with its own floor. Set only on an
|
|
102
|
+
* asking chain winner: the serving node of a forwarded ask judges each one,
|
|
103
|
+
* since the winner alone would approve commands it never saw.
|
|
104
|
+
*/
|
|
105
|
+
askingUnits?: readonly AskingBashUnit[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One unit of a bash chain the gate left asking, as the serving node of a
|
|
110
|
+
* forwarded ask needs it: the unit as typed, and the floor that raised it.
|
|
111
|
+
*/
|
|
112
|
+
export interface AskingBashUnit {
|
|
113
|
+
command: string;
|
|
114
|
+
floor?: string;
|
|
92
115
|
}
|
|
93
116
|
|
|
94
117
|
export function isPermissionState(value: unknown): value is PermissionState {
|