@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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDeniedPermissionDecision,
|
|
3
3
|
normalizePermissionDenialReason,
|
|
4
|
-
type PermissionPromptDecision,
|
|
5
4
|
type RequestPermissionOptions,
|
|
5
|
+
type UnattributedDecision,
|
|
6
6
|
} from "#src/authority/permission-dialog";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -70,7 +70,7 @@ export type PromptEvent =
|
|
|
70
70
|
/** Either a re-render or a terminal decision. */
|
|
71
71
|
export type PromptOutcome =
|
|
72
72
|
| { kind: "render"; state: PromptViewState }
|
|
73
|
-
| { kind: "decision"; decision:
|
|
73
|
+
| { kind: "decision"; decision: UnattributedDecision };
|
|
74
74
|
|
|
75
75
|
export function initialPromptState(
|
|
76
76
|
_config: PromptModelConfig,
|
|
@@ -88,7 +88,11 @@ export function initialPromptState(
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Advance the dialog by one input event, returning either the next view state
|
|
91
|
-
* to render or the committed {@link
|
|
91
|
+
* to render or the committed {@link UnattributedDecision}.
|
|
92
|
+
*
|
|
93
|
+
* The model states the outcome and not the decider: which human surface this
|
|
94
|
+
* is gets attributed by the dispatcher that chose to render this dialog, so
|
|
95
|
+
* the two cannot disagree about the surface.
|
|
92
96
|
*/
|
|
93
97
|
export function reducePrompt(
|
|
94
98
|
config: PromptModelConfig,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
import type {
|
|
3
4
|
ForwardedAccessFacts,
|
|
@@ -131,6 +132,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
131
132
|
? "confirmation_unavailable"
|
|
132
133
|
: decision.state,
|
|
133
134
|
denialReason: decision.denialReason,
|
|
135
|
+
decidedBy: decision.decidedBy,
|
|
134
136
|
},
|
|
135
137
|
);
|
|
136
138
|
|
|
@@ -139,14 +141,20 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
139
141
|
|
|
140
142
|
// ── Private helpers ──────────────────────────────────────────────────────
|
|
141
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The `waiting` entry carries no `decidedBy` — nothing has decided yet, and
|
|
146
|
+
* a `null` there would read as "decided by nobody" rather than "not yet".
|
|
147
|
+
*/
|
|
142
148
|
private writeReviewEntry(
|
|
143
149
|
event: string,
|
|
144
150
|
details: PromptPermissionDetails & {
|
|
145
151
|
resolution?: string;
|
|
146
152
|
denialReason?: string;
|
|
153
|
+
decidedBy?: DecisionSource;
|
|
147
154
|
},
|
|
148
155
|
): void {
|
|
149
156
|
this.deps.logger.review(event, {
|
|
157
|
+
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
|
150
158
|
requestId: details.requestId,
|
|
151
159
|
source: details.source,
|
|
152
160
|
agentName: details.agentName,
|
|
@@ -35,10 +35,43 @@ export const SERVING_SESSION_REGISTRY_KEY = Symbol.for(
|
|
|
35
35
|
* neither reads the store nor gains a query it has no business making (ISP).
|
|
36
36
|
*/
|
|
37
37
|
export interface ServingAnnouncer {
|
|
38
|
+
/**
|
|
39
|
+
* Record that `sessionId` is polling its inbox.
|
|
40
|
+
*
|
|
41
|
+
* Idempotent, and called on every poll tick rather than once per session: an
|
|
42
|
+
* announcement that can decay (the filesystem heartbeat) has to be kept
|
|
43
|
+
* current, and one that cannot (this registry) costs a set insertion to say
|
|
44
|
+
* so again.
|
|
45
|
+
*/
|
|
38
46
|
markServing(sessionId: string): void;
|
|
39
47
|
clearServing(sessionId: string): void;
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Fan an announcement out to every channel a serving session publishes on.
|
|
52
|
+
*
|
|
53
|
+
* A session announces to the process-global registry (for its in-process
|
|
54
|
+
* children) and to the filesystem (for children in other processes). Composing
|
|
55
|
+
* them keeps `ForwardingManager` holding one collaborator, so adding or
|
|
56
|
+
* removing a channel never reaches the poll loop.
|
|
57
|
+
*/
|
|
58
|
+
export function composeServingAnnouncers(
|
|
59
|
+
...announcers: readonly ServingAnnouncer[]
|
|
60
|
+
): ServingAnnouncer {
|
|
61
|
+
return {
|
|
62
|
+
markServing(sessionId: string): void {
|
|
63
|
+
for (const announcer of announcers) {
|
|
64
|
+
announcer.markServing(sessionId);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
clearServing(sessionId: string): void {
|
|
68
|
+
for (const announcer of announcers) {
|
|
69
|
+
announcer.clearServing(sessionId);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
42
75
|
/**
|
|
43
76
|
* Query-side seam: a forwarding child asks whether its target is draining.
|
|
44
77
|
*
|
|
@@ -47,6 +47,15 @@ export function describeBashExternalDirectoryGate(
|
|
|
47
47
|
if (uncoveredPaths.length === 0) {
|
|
48
48
|
return {
|
|
49
49
|
action: "allow",
|
|
50
|
+
// A whole-command bypass covers every external path at once, and each
|
|
51
|
+
// may have matched a different session pattern -- so the surface is one
|
|
52
|
+
// value and the pattern is not. The entry's `externalPaths` lists what
|
|
53
|
+
// was covered.
|
|
54
|
+
decidedBy: {
|
|
55
|
+
kind: "session_approval",
|
|
56
|
+
surface: "external_directory",
|
|
57
|
+
pattern: null,
|
|
58
|
+
},
|
|
50
59
|
log: {
|
|
51
60
|
event: "permission_request.session_approved",
|
|
52
61
|
details: {
|
|
@@ -84,6 +84,14 @@ export function describeBashPathGate(
|
|
|
84
84
|
if (allSessionCovered) {
|
|
85
85
|
return {
|
|
86
86
|
action: "allow",
|
|
87
|
+
// Every token was covered, each possibly by a different session pattern
|
|
88
|
+
// -- the surface is one value and the pattern is not. The entry's
|
|
89
|
+
// `tokens` lists what was covered.
|
|
90
|
+
decidedBy: {
|
|
91
|
+
kind: "session_approval",
|
|
92
|
+
surface: "path",
|
|
93
|
+
pattern: null,
|
|
94
|
+
},
|
|
87
95
|
log: {
|
|
88
96
|
event: "permission_request.session_approved",
|
|
89
97
|
details: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
|
|
2
3
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
3
4
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
@@ -79,6 +80,14 @@ export type DecisionEventFacts = Omit<PermissionDecisionEvent, "requestId">;
|
|
|
79
80
|
*/
|
|
80
81
|
export interface GateBypass {
|
|
81
82
|
action: "allow";
|
|
83
|
+
/**
|
|
84
|
+
* What decided this short-circuit.
|
|
85
|
+
*
|
|
86
|
+
* The gate that bypasses *is* the decider, so it states its own provenance
|
|
87
|
+
* and the runner relays it onto the log entry rather than inferring one from
|
|
88
|
+
* the event name (#726). Required, so a bypass added later cannot omit it.
|
|
89
|
+
*/
|
|
90
|
+
decidedBy: DecisionSource;
|
|
82
91
|
/** Optional review log entry to emit. */
|
|
83
92
|
log?: { event: string; details: Record<string, unknown> };
|
|
84
93
|
/** Optional decision event to emit. */
|
|
@@ -45,6 +45,8 @@ export function describeExternalDirectoryGate(
|
|
|
45
45
|
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
|
|
46
46
|
return {
|
|
47
47
|
action: "allow",
|
|
48
|
+
// Containment allowed this, not a rule the operator wrote.
|
|
49
|
+
decidedBy: { kind: "infrastructure_read" },
|
|
48
50
|
log: {
|
|
49
51
|
event: "permission_request.infrastructure_auto_allowed",
|
|
50
52
|
details: {
|
|
@@ -66,6 +66,7 @@ export class GateRunner {
|
|
|
66
66
|
this.reporter.writeReviewLog(gate.log.event, {
|
|
67
67
|
...gate.log.details,
|
|
68
68
|
requestId,
|
|
69
|
+
decidedBy: gate.decidedBy,
|
|
69
70
|
});
|
|
70
71
|
}
|
|
71
72
|
if (gate.decision) {
|
|
@@ -123,12 +124,22 @@ export class GateRunner {
|
|
|
123
124
|
requestId,
|
|
124
125
|
};
|
|
125
126
|
|
|
127
|
+
// Each resolution below states its own decider. The provenance is built
|
|
128
|
+
// at the branch that decides rather than merged into `logContext`: that
|
|
129
|
+
// context holds what every resolution of this gate shares, and who decided
|
|
130
|
+
// is by definition not shared (#726).
|
|
131
|
+
|
|
126
132
|
// 2. Session-hit fast path
|
|
127
133
|
if (check.source === "session") {
|
|
128
134
|
this.reporter.writeReviewLog("permission_request.session_approved", {
|
|
129
135
|
...logContext,
|
|
130
136
|
resolution: "session_approved",
|
|
131
137
|
sessionApprovalPattern: check.matchedPattern,
|
|
138
|
+
decidedBy: {
|
|
139
|
+
kind: "session_approval",
|
|
140
|
+
surface: descriptor.surface,
|
|
141
|
+
pattern: check.matchedPattern ?? null,
|
|
142
|
+
},
|
|
132
143
|
});
|
|
133
144
|
this.emitDecision(
|
|
134
145
|
requestId,
|
|
@@ -152,6 +163,9 @@ export class GateRunner {
|
|
|
152
163
|
this.reporter.writeReviewLog("permission_request.auto_approved", {
|
|
153
164
|
...logContext,
|
|
154
165
|
resolution: "auto_approved",
|
|
166
|
+
// The pattern that raised the ask, sentinel included: "yolo allowed
|
|
167
|
+
// it" alone does not say why it was asked in the first place.
|
|
168
|
+
decidedBy: { kind: "yolo", pattern: check.matchedPattern ?? null },
|
|
155
169
|
});
|
|
156
170
|
this.emitDecision(
|
|
157
171
|
requestId,
|
|
@@ -202,6 +216,12 @@ export class GateRunner {
|
|
|
202
216
|
writeLog: (event, details) =>
|
|
203
217
|
this.reporter.writeReviewLog(event, details),
|
|
204
218
|
logContext,
|
|
219
|
+
decidedByRule: {
|
|
220
|
+
kind: "rule",
|
|
221
|
+
surface: descriptor.surface,
|
|
222
|
+
pattern: check.matchedPattern ?? null,
|
|
223
|
+
origin: check.origin,
|
|
224
|
+
},
|
|
205
225
|
messages,
|
|
206
226
|
});
|
|
207
227
|
|
|
@@ -76,12 +76,15 @@ function recordGateError(
|
|
|
76
76
|
): void {
|
|
77
77
|
try {
|
|
78
78
|
audit.recordError();
|
|
79
|
+
const reason = errorMessage(error);
|
|
79
80
|
reporter.writeReviewLog("permission_request.blocked", {
|
|
80
81
|
requestId: createPermissionRequestId(),
|
|
81
82
|
toolName: bestEffortToolName(event),
|
|
82
83
|
command: bestEffortCommand(event),
|
|
83
84
|
resolution: "gate_error",
|
|
84
|
-
error:
|
|
85
|
+
error: reason,
|
|
86
|
+
// The boundary decided, by failing closed -- no rule and no human did.
|
|
87
|
+
decidedBy: { kind: "gate_error", reason },
|
|
85
88
|
});
|
|
86
89
|
} catch {
|
|
87
90
|
// The block is the guarantee; its bookkeeping is not.
|
package/src/index.ts
CHANGED
|
@@ -8,11 +8,18 @@ import {
|
|
|
8
8
|
ForwardedRequestServer,
|
|
9
9
|
type ServingPolicy,
|
|
10
10
|
} from "./authority/forwarded-request-server";
|
|
11
|
+
import {
|
|
12
|
+
ForwardingLivenessJudge,
|
|
13
|
+
ServingHeartbeatStore,
|
|
14
|
+
} from "./authority/forwarding-liveness";
|
|
11
15
|
import { ForwardingManager } from "./authority/forwarding-manager";
|
|
12
16
|
import { PERMISSION_FORWARDING_TIMEOUT_MS } from "./authority/permission-forwarding";
|
|
13
17
|
import { requestPermissionDecision } from "./authority/permission-prompt-component";
|
|
14
18
|
import { PermissionPrompter } from "./authority/permission-prompter";
|
|
15
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
composeServingAnnouncers,
|
|
21
|
+
getServingSessionRegistry,
|
|
22
|
+
} from "./authority/serving-registry";
|
|
16
23
|
import { SubagentDetection } from "./authority/subagent-detection";
|
|
17
24
|
import { subscribeSubagentLifecycle } from "./authority/subagent-lifecycle-events";
|
|
18
25
|
import { getSubagentSessionRegistry } from "./authority/subagent-registry";
|
|
@@ -111,6 +118,20 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
111
118
|
|
|
112
119
|
const prompter = new PermissionPrompter({ logger });
|
|
113
120
|
|
|
121
|
+
// The filesystem half of the serving announcement. `servingRegistry` reaches
|
|
122
|
+
// an in-process child through `globalThis`; a child in its own process shares
|
|
123
|
+
// nothing but this directory, so the served session publishes a heartbeat
|
|
124
|
+
// there too (#721).
|
|
125
|
+
const servingHeartbeats = new ServingHeartbeatStore({
|
|
126
|
+
forwardingDir: paths.forwardingDir,
|
|
127
|
+
logger,
|
|
128
|
+
});
|
|
129
|
+
// The read side of both channels, routed by how the target was resolved.
|
|
130
|
+
const servingLiveness = new ForwardingLivenessJudge({
|
|
131
|
+
registry: servingRegistry,
|
|
132
|
+
heartbeats: servingHeartbeats,
|
|
133
|
+
});
|
|
134
|
+
|
|
114
135
|
const authorizerSelection = new AuthorizerSelection({
|
|
115
136
|
detection: subagentDetection,
|
|
116
137
|
events: pi.events,
|
|
@@ -121,7 +142,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
121
142
|
requestPermissionDecision,
|
|
122
143
|
forwardingDir: paths.forwardingDir,
|
|
123
144
|
registry: subagentRegistry,
|
|
124
|
-
|
|
145
|
+
serving: servingLiveness,
|
|
125
146
|
getForwardingTimeoutMs: () =>
|
|
126
147
|
configStore.current().forwardingTimeoutMs ??
|
|
127
148
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
@@ -177,7 +198,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
177
198
|
new ForwardingManager({
|
|
178
199
|
detection: subagentDetection,
|
|
179
200
|
forwarder: requestServer,
|
|
180
|
-
serving: servingRegistry,
|
|
201
|
+
serving: composeServingAnnouncers(servingRegistry, servingHeartbeats),
|
|
181
202
|
logger,
|
|
182
203
|
}),
|
|
183
204
|
permissionManager,
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DecisionSource } from "#src/authority/decision-source";
|
|
1
2
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
2
3
|
|
|
3
4
|
/** Result of applying the permission gate. */
|
|
@@ -30,6 +31,15 @@ export interface PermissionGateParams {
|
|
|
30
31
|
/** Log context fields shared across all log calls for this gate. */
|
|
31
32
|
logContext: Record<string, unknown>;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The rule that resolved this gate, for the deny arm's review entry.
|
|
36
|
+
*
|
|
37
|
+
* A sibling of `logContext` rather than a member of it: the context holds
|
|
38
|
+
* what every resolution of this gate shares, and the decider is by
|
|
39
|
+
* definition not shared (#726).
|
|
40
|
+
*/
|
|
41
|
+
decidedByRule: DecisionSource;
|
|
42
|
+
|
|
33
43
|
/** Message strings/factories for each outcome. */
|
|
34
44
|
messages: {
|
|
35
45
|
denyReason: string;
|
|
@@ -52,6 +62,7 @@ export async function applyPermissionGate(
|
|
|
52
62
|
writeLog("permission_request.blocked", {
|
|
53
63
|
...logContext,
|
|
54
64
|
resolution: "policy_denied",
|
|
65
|
+
decidedBy: params.decidedByRule,
|
|
55
66
|
});
|
|
56
67
|
return { action: "block", reason: messages.denyReason };
|
|
57
68
|
}
|