@gotgenes/pi-permission-system 26.0.0 → 26.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/docs/configuration.md +14 -14
- package/docs/subagent-integration.md +36 -3
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +49 -16
- package/src/authority/authorizer-chain.ts +39 -11
- package/src/authority/authorizer-selection.ts +5 -5
- package/src/authority/authorizer.ts +17 -4
- package/src/authority/decision-source.ts +235 -0
- package/src/authority/denying-authorizer.ts +4 -0
- package/src/authority/forwarded-request-server.ts +44 -12
- package/src/authority/forwarding-io.ts +5 -0
- package/src/authority/forwarding-liveness.ts +450 -0
- package/src/authority/forwarding-manager.ts +21 -0
- package/src/authority/permission-dialog.ts +23 -2
- package/src/authority/permission-forwarding.ts +22 -1
- package/src/authority/permission-prompt-component.ts +34 -10
- package/src/authority/permission-prompt-decision.ts +7 -3
- package/src/authority/permission-prompter.ts +8 -0
- package/src/authority/serving-registry.ts +33 -0
- package/src/handlers/gates/bash-external-directory.ts +9 -0
- package/src/handlers/gates/bash-path.ts +8 -0
- package/src/handlers/gates/descriptor.ts +9 -0
- package/src/handlers/gates/external-directory.ts +2 -0
- package/src/handlers/gates/runner.ts +20 -0
- package/src/handlers/tool-call-boundary.ts +4 -1
- package/src/index.ts +24 -3
- package/src/permission-gate.ts +11 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What decided a permission request, recorded at the site that decided it.
|
|
3
|
+
*
|
|
4
|
+
* The decision paths are already distinct in the code — a session hit, a yolo
|
|
5
|
+
* grant, an infrastructure read, a config rule, a chain link, a human at a
|
|
6
|
+
* dialog, an unreachable authority — and each one knows what it is at the
|
|
7
|
+
* moment it decides. This is that fact, carried to the record instead of being
|
|
8
|
+
* discarded and re-guessed from an event name downstream.
|
|
9
|
+
*
|
|
10
|
+
* Every variant is **self-contained**: it repeats the detail that made it
|
|
11
|
+
* decisive rather than leaning on a sibling log column. That duplicates
|
|
12
|
+
* `surface` and the pattern on a local review line, and it is the only shape
|
|
13
|
+
* that survives the forwarding hop, where the response file has no such
|
|
14
|
+
* columns to lean on.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Which human-facing surface the operator answered on. */
|
|
18
|
+
export type UserDecisionSurface = "dialog" | "select";
|
|
19
|
+
|
|
20
|
+
export type DecisionSource =
|
|
21
|
+
/** A human ruled, at the inline dialog or the `select`/`input` fallback. */
|
|
22
|
+
| { kind: "user"; via: UserDecisionSurface }
|
|
23
|
+
/** A registered `authorizerChain` link ruled; `name` is the configured name. */
|
|
24
|
+
| {
|
|
25
|
+
kind: "authorizer";
|
|
26
|
+
name: string;
|
|
27
|
+
verdict: "allow" | "deny";
|
|
28
|
+
reason: string | null;
|
|
29
|
+
}
|
|
30
|
+
/** Recorded authority: a rule in the composed ruleset matched. */
|
|
31
|
+
| {
|
|
32
|
+
kind: "rule";
|
|
33
|
+
surface: string;
|
|
34
|
+
pattern: string | null;
|
|
35
|
+
origin: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** A session-scoped grant the operator made earlier in this session. */
|
|
38
|
+
| { kind: "session_approval"; surface: string; pattern: string | null }
|
|
39
|
+
/**
|
|
40
|
+
* `yoloMode`. `pattern` preserves the ask's matched rule — including a
|
|
41
|
+
* synthetic sentinel such as `<opaque-bash-wrapper>` — which is what makes a
|
|
42
|
+
* yolo grant over a synthesized ask legible.
|
|
43
|
+
*/
|
|
44
|
+
| { kind: "yolo"; pattern: string | null }
|
|
45
|
+
/** A Pi infrastructure read, allowed by containment rather than by a rule. */
|
|
46
|
+
| { kind: "infrastructure_read" }
|
|
47
|
+
/**
|
|
48
|
+
* No authority ever ruled: none was reachable, or the forwarding path gave
|
|
49
|
+
* up before reaching one. `reason` names which path gave up.
|
|
50
|
+
*/
|
|
51
|
+
| { kind: "unavailable"; reason: string }
|
|
52
|
+
/** A gate threw, and the boundary blocked rather than allowed. */
|
|
53
|
+
| { kind: "gate_error"; reason: string }
|
|
54
|
+
/**
|
|
55
|
+
* Another session decided. Recursive by design: the requesting side records
|
|
56
|
+
* both that the decider was elsewhere and what, within that session, decided
|
|
57
|
+
* — which is the distinction an audit of a forwarded ask needs.
|
|
58
|
+
*
|
|
59
|
+
* `decision` is `null` when the responder sent none (an older parent).
|
|
60
|
+
*/
|
|
61
|
+
| {
|
|
62
|
+
kind: "forwarded";
|
|
63
|
+
responderSessionId: string | null;
|
|
64
|
+
decision: DecisionSource | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How deep a `forwarded` chain may nest before {@link asDecisionSource} gives
|
|
69
|
+
* up.
|
|
70
|
+
*
|
|
71
|
+
* Forwarding is depth-1 by invariant (child → root) and a relay hop makes it
|
|
72
|
+
* two, so this is headroom rather than a working limit. It exists because the
|
|
73
|
+
* value is read off disk: a recursive reader over a file another process wrote
|
|
74
|
+
* is a stack-overflow surface, and the fail-closed answer is to stop.
|
|
75
|
+
*/
|
|
76
|
+
export const MAX_DECISION_SOURCE_DEPTH = 4;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Narrow an unknown value to a {@link DecisionSource}, or `undefined`.
|
|
80
|
+
*
|
|
81
|
+
* Lives beside its type so a new variant updates the guard next door, following
|
|
82
|
+
* `asPromptPayload` and `isPermissionDecisionState`. All-or-nothing: a
|
|
83
|
+
* malformed field — at any nesting level — yields `undefined` rather than a
|
|
84
|
+
* half-parsed record, because a provenance record that names a decider who did
|
|
85
|
+
* not decide is worse than one that names none.
|
|
86
|
+
*/
|
|
87
|
+
export function asDecisionSource(value: unknown): DecisionSource | undefined {
|
|
88
|
+
return narrowSource(value, MAX_DECISION_SOURCE_DEPTH);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function narrowSource(
|
|
92
|
+
value: unknown,
|
|
93
|
+
depthBudget: number,
|
|
94
|
+
): DecisionSource | undefined {
|
|
95
|
+
const candidate = asObject(value);
|
|
96
|
+
if (!candidate) return undefined;
|
|
97
|
+
|
|
98
|
+
switch (candidate.kind) {
|
|
99
|
+
case "user":
|
|
100
|
+
return narrowUser(candidate);
|
|
101
|
+
case "authorizer":
|
|
102
|
+
return narrowAuthorizer(candidate);
|
|
103
|
+
case "rule":
|
|
104
|
+
return narrowRule(candidate);
|
|
105
|
+
case "session_approval":
|
|
106
|
+
return narrowSessionApproval(candidate);
|
|
107
|
+
case "yolo":
|
|
108
|
+
return isNullableString(candidate.pattern)
|
|
109
|
+
? { kind: "yolo", pattern: candidate.pattern }
|
|
110
|
+
: undefined;
|
|
111
|
+
case "infrastructure_read":
|
|
112
|
+
return { kind: "infrastructure_read" };
|
|
113
|
+
case "unavailable":
|
|
114
|
+
return typeof candidate.reason === "string"
|
|
115
|
+
? { kind: "unavailable", reason: candidate.reason }
|
|
116
|
+
: undefined;
|
|
117
|
+
case "gate_error":
|
|
118
|
+
return typeof candidate.reason === "string"
|
|
119
|
+
? { kind: "gate_error", reason: candidate.reason }
|
|
120
|
+
: undefined;
|
|
121
|
+
case "forwarded":
|
|
122
|
+
return narrowForwarded(candidate, depthBudget);
|
|
123
|
+
default:
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function narrowUser(
|
|
129
|
+
candidate: Record<string, unknown>,
|
|
130
|
+
): DecisionSource | undefined {
|
|
131
|
+
const via = USER_DECISION_SURFACES.find((entry) => entry === candidate.via);
|
|
132
|
+
return via ? { kind: "user", via } : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function narrowAuthorizer(
|
|
136
|
+
candidate: Record<string, unknown>,
|
|
137
|
+
): DecisionSource | undefined {
|
|
138
|
+
const verdict = AUTHORIZER_VERDICTS.find(
|
|
139
|
+
(entry) => entry === candidate.verdict,
|
|
140
|
+
);
|
|
141
|
+
if (
|
|
142
|
+
!verdict ||
|
|
143
|
+
typeof candidate.name !== "string" ||
|
|
144
|
+
!isNullableString(candidate.reason)
|
|
145
|
+
) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: "authorizer",
|
|
150
|
+
name: candidate.name,
|
|
151
|
+
verdict,
|
|
152
|
+
reason: candidate.reason,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function narrowRule(
|
|
157
|
+
candidate: Record<string, unknown>,
|
|
158
|
+
): DecisionSource | undefined {
|
|
159
|
+
if (
|
|
160
|
+
typeof candidate.surface !== "string" ||
|
|
161
|
+
!isNullableString(candidate.pattern) ||
|
|
162
|
+
!isNullableString(candidate.origin)
|
|
163
|
+
) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
kind: "rule",
|
|
168
|
+
surface: candidate.surface,
|
|
169
|
+
pattern: candidate.pattern,
|
|
170
|
+
origin: candidate.origin,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function narrowSessionApproval(
|
|
175
|
+
candidate: Record<string, unknown>,
|
|
176
|
+
): DecisionSource | undefined {
|
|
177
|
+
if (
|
|
178
|
+
typeof candidate.surface !== "string" ||
|
|
179
|
+
!isNullableString(candidate.pattern)
|
|
180
|
+
) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
kind: "session_approval",
|
|
185
|
+
surface: candidate.surface,
|
|
186
|
+
pattern: candidate.pattern,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The inner decision is narrowed against a decremented budget, so a chain
|
|
192
|
+
* deeper than {@link MAX_DECISION_SOURCE_DEPTH} is rejected whole rather than
|
|
193
|
+
* truncated — a truncated chain would silently attribute the decision to the
|
|
194
|
+
* last frame that fit.
|
|
195
|
+
*/
|
|
196
|
+
function narrowForwarded(
|
|
197
|
+
candidate: Record<string, unknown>,
|
|
198
|
+
depthBudget: number,
|
|
199
|
+
): DecisionSource | undefined {
|
|
200
|
+
if (depthBudget <= 0 || !isNullableString(candidate.responderSessionId)) {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
if (candidate.decision === null) {
|
|
204
|
+
return {
|
|
205
|
+
kind: "forwarded",
|
|
206
|
+
responderSessionId: candidate.responderSessionId,
|
|
207
|
+
decision: null,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const decision = narrowSource(candidate.decision, depthBudget - 1);
|
|
211
|
+
return decision
|
|
212
|
+
? {
|
|
213
|
+
kind: "forwarded",
|
|
214
|
+
responderSessionId: candidate.responderSessionId,
|
|
215
|
+
decision,
|
|
216
|
+
}
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const USER_DECISION_SURFACES = [
|
|
221
|
+
"dialog",
|
|
222
|
+
"select",
|
|
223
|
+
] as const satisfies readonly UserDecisionSurface[];
|
|
224
|
+
|
|
225
|
+
const AUTHORIZER_VERDICTS = ["allow", "deny"] as const;
|
|
226
|
+
|
|
227
|
+
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
228
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
229
|
+
? (value as Record<string, unknown>)
|
|
230
|
+
: undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isNullableString(value: unknown): value is string | null {
|
|
234
|
+
return value === null || typeof value === "string";
|
|
235
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
2
|
import type { TerminalAuthorizer } from "./authorizer";
|
|
3
3
|
|
|
4
|
+
/** Why this authorizer denies; the provenance record's `reason` (#726). */
|
|
5
|
+
const NO_AUTHORITY_REASON = "No live authority was reachable for this session";
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Least-privilege Authorizer: no authority is reachable for this session
|
|
6
9
|
* (no UI, not a subagent), so every ask is denied.
|
|
@@ -15,6 +18,7 @@ export class DenyingAuthorizer implements TerminalAuthorizer {
|
|
|
15
18
|
approved: false,
|
|
16
19
|
state: "denied",
|
|
17
20
|
confirmationUnavailable: true,
|
|
21
|
+
decidedBy: { kind: "unavailable", reason: NO_AUTHORITY_REASON },
|
|
18
22
|
});
|
|
19
23
|
}
|
|
20
24
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import {
|
|
3
4
|
type ForwarderContext,
|
|
4
5
|
getSessionId,
|
|
@@ -22,6 +23,7 @@ import type { AskEscalator } from "./authorizer-selection";
|
|
|
22
23
|
import {
|
|
23
24
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
24
25
|
ensureDirectoryExists,
|
|
26
|
+
formatUnknownErrorMessage,
|
|
25
27
|
getExistingPermissionForwardingLocation,
|
|
26
28
|
listRequestFiles,
|
|
27
29
|
logPermissionForwardingError,
|
|
@@ -276,6 +278,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
276
278
|
* `approved` so the child records nothing (its next identical action
|
|
277
279
|
* re-forwards and resolves as recorded authority). Every other decision
|
|
278
280
|
* passes through unchanged (`approved_for_session` → the child records).
|
|
281
|
+
*
|
|
282
|
+
* The translation rewrites the grant's *scope*, never its decider: the human
|
|
283
|
+
* who chose the wider scope is still the one who decided (#726).
|
|
279
284
|
*/
|
|
280
285
|
private applyGrantScope(
|
|
281
286
|
request: ForwardedPermissionRequest,
|
|
@@ -298,7 +303,11 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
298
303
|
patterns: request.sessionApproval.patterns,
|
|
299
304
|
});
|
|
300
305
|
}
|
|
301
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
approved: true,
|
|
308
|
+
state: "approved",
|
|
309
|
+
decidedBy: decision.decidedBy,
|
|
310
|
+
};
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
/**
|
|
@@ -327,6 +336,7 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
327
336
|
responsePath,
|
|
328
337
|
resolution: decision.state,
|
|
329
338
|
denialReason: decision.denialReason ?? null,
|
|
339
|
+
decidedBy: decision.decidedBy,
|
|
330
340
|
},
|
|
331
341
|
);
|
|
332
342
|
try {
|
|
@@ -336,6 +346,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
336
346
|
denialReason: decision.denialReason,
|
|
337
347
|
responderSessionId: currentSessionId,
|
|
338
348
|
respondedAt: Date.now(),
|
|
349
|
+
// Carried onto the wire so the requester can name what decided inside
|
|
350
|
+
// this session, not merely that this session answered (#726).
|
|
351
|
+
decidedBy: decision.decidedBy,
|
|
339
352
|
} satisfies ForwardedPermissionResponse);
|
|
340
353
|
} catch (error) {
|
|
341
354
|
logPermissionForwardingError(
|
|
@@ -366,29 +379,48 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
366
379
|
request: ForwardedPermissionRequest,
|
|
367
380
|
logDetails: Record<string, unknown>,
|
|
368
381
|
): Promise<PermissionPromptDecision> {
|
|
369
|
-
const
|
|
370
|
-
? this.policy.resolve(request.accessIntent)
|
|
371
|
-
:
|
|
382
|
+
const check = request.accessIntent
|
|
383
|
+
? this.policy.resolve(request.accessIntent)
|
|
384
|
+
: null;
|
|
372
385
|
|
|
373
|
-
if (state
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
386
|
+
if (check && check.state !== "ask") {
|
|
387
|
+
// The rule is carried in full rather than left to the event name: the
|
|
388
|
+
// response file has no surface, pattern, or origin column for the
|
|
389
|
+
// requester's record to lean on.
|
|
390
|
+
const decidedBy: DecisionSource = {
|
|
391
|
+
kind: "rule",
|
|
392
|
+
surface: request.accessIntent?.surface ?? check.toolName,
|
|
393
|
+
pattern: check.matchedPattern ?? null,
|
|
394
|
+
origin: check.origin,
|
|
395
|
+
};
|
|
396
|
+
const approved = check.state === "allow";
|
|
397
|
+
this.logger.review(
|
|
398
|
+
approved
|
|
399
|
+
? "forwarded_permission.auto_approved"
|
|
400
|
+
: "forwarded_permission.auto_denied",
|
|
401
|
+
{ ...logDetails, decidedBy },
|
|
402
|
+
);
|
|
403
|
+
return approved
|
|
404
|
+
? { approved: true, state: "approved", decidedBy }
|
|
405
|
+
: { approved: false, state: "denied", decidedBy };
|
|
380
406
|
}
|
|
381
407
|
|
|
382
408
|
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
383
409
|
try {
|
|
384
410
|
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
|
385
411
|
} catch (error) {
|
|
412
|
+
const reason = formatUnknownErrorMessage(error);
|
|
386
413
|
logPermissionForwardingError(
|
|
387
414
|
this.logger,
|
|
388
415
|
`Failed to escalate forwarded permission request '${request.id}'`,
|
|
389
416
|
error,
|
|
390
417
|
);
|
|
391
|
-
|
|
418
|
+
// Nobody denied this; the escalation broke and the node failed closed.
|
|
419
|
+
return {
|
|
420
|
+
approved: false,
|
|
421
|
+
state: "denied",
|
|
422
|
+
decidedBy: { kind: "gate_error", reason },
|
|
423
|
+
};
|
|
392
424
|
}
|
|
393
425
|
}
|
|
394
426
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
+
import { asDecisionSource } from "#src/authority/decision-source";
|
|
12
13
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
13
14
|
import {
|
|
14
15
|
createPermissionForwardingLocation,
|
|
@@ -466,6 +467,10 @@ export function readForwardedPermissionResponse(
|
|
|
466
467
|
typeof parsed.respondedAt === "number"
|
|
467
468
|
? parsed.respondedAt
|
|
468
469
|
: Date.now(),
|
|
470
|
+
// Tolerant like the request's `accessIntent`: an unusable provenance
|
|
471
|
+
// record is dropped, but the decision itself still has to reach the
|
|
472
|
+
// requester, so it never rejects the response.
|
|
473
|
+
decidedBy: asDecisionSource(parsed.decidedBy),
|
|
469
474
|
};
|
|
470
475
|
} catch (error) {
|
|
471
476
|
logPermissionForwardingWarning(
|