@gotgenes/pi-permission-system 39.1.1 → 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 +11 -0
- package/dist/public.d.ts +23 -0
- package/docs/configuration.md +1 -0
- package/docs/subagent-integration.md +1 -0
- package/package.json +1 -1
- package/src/authority/forwarded-request-server.ts +6 -1
- package/src/authority/forwarding-io.ts +44 -0
- package/src/authority/permission-forwarding.ts +9 -0
- package/src/handlers/gates/bash-command.ts +24 -1
- package/src/handlers/gates/tool.ts +15 -1
- package/src/policy/serving-policy.ts +40 -1
- package/src/types.ts +16 -0
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.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
|
+
|
|
8
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)
|
|
9
20
|
|
|
10
21
|
|
package/dist/public.d.ts
CHANGED
|
@@ -76,6 +76,21 @@ interface PermissionCheckResult {
|
|
|
76
76
|
* node to clamp its own `allow` with.
|
|
77
77
|
*/
|
|
78
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;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
/**
|
|
@@ -385,6 +400,14 @@ interface ForwardedAccessFacts {
|
|
|
385
400
|
* than recomputing it. Absent when no floor was raised.
|
|
386
401
|
*/
|
|
387
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[];
|
|
388
411
|
}
|
|
389
412
|
|
|
390
413
|
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
package/docs/configuration.md
CHANGED
|
@@ -541,6 +541,7 @@ Approving one for the session works normally: the floors clamp the decision and
|
|
|
541
541
|
A subagent's floored ask keeps its floor when it is forwarded to the parent session.
|
|
542
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
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.
|
|
544
545
|
|
|
545
546
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
546
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.
|
|
@@ -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
|
+
For a chained bash command, the parent judges every command the child left asking, not only the first, and the most restrictive answer decides.
|
|
151
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.
|
|
152
153
|
|
|
153
154
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
package/package.json
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
package/src/types.ts
CHANGED
|
@@ -96,6 +96,22 @@ export interface PermissionCheckResult {
|
|
|
96
96
|
* node to clamp its own `allow` with.
|
|
97
97
|
*/
|
|
98
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;
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
export function isPermissionState(value: unknown): value is PermissionState {
|