@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.
@@ -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: PermissionPromptDecision };
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 PermissionPromptDecision}.
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: errorMessage(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 { getServingSessionRegistry } from "./authority/serving-registry";
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
- servingRegistry,
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,
@@ -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
  }