pi-auto-permissions 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -165,9 +165,11 @@ writes only to the workspace and temporary roots, keeps protected metadata and
165
165
  the extension's durable state read-only, denies network and local binding, and
166
166
  applies to descendant processes.
167
167
 
168
- If direct-file path policy construction fails at startup, the fallback admits
169
- only the four known read-only standard tools and statically denies every `write` or
170
- `edit`. It does not guess path safety or ask the reviewer to replace missing
168
+ Read-only standard tools and installed custom tools bypass permission runtime
169
+ state entirely, preserving their native success, error, and cancellation
170
+ results. If direct-file path policy construction fails at startup, the fallback
171
+ admits only the four known read-only standard tools and statically denies every
172
+ `write` or `edit`. It does not guess path safety or ask the reviewer to replace missing
171
173
  classification.
172
174
 
173
175
  Only reviewed shell actions reach the reviewer. Its complete semantic policy is:
@@ -187,11 +189,12 @@ Text answers, missing decisions, multiple decisions, and decision calls mixed
187
189
  with investigation calls are re-prompted up to twice; only one valid decision
188
190
  tool call is accepted. The adapter converts that structured call into a local
189
191
  one-field verdict. Exhausted re-prompts, timeouts, missing credentials, provider
190
- errors, cancellation, state changes, and every other non-allow outcome deny.
191
- Reviews have one aggregate 90-second deadline, at most three retryable model
192
- attempts, and denial circuit breakers to prevent loops. The model always sees
193
- one fixed denial message, while the user notification identifies whether the
194
- reviewer actually called `deny` or failed for a protocol/runtime reason.
192
+ errors, and state changes block execution but are reported as permission-review
193
+ failures, not Guardian denials. User cancellation is reported as `Operation
194
+ aborted`, emits no denial notification, and does not count toward denial circuit
195
+ breakers. Only an actual `deny` decision receives the fixed permission-denied
196
+ message. Reviews have one aggregate 90-second deadline, at most three retryable
197
+ model attempts, and denial circuit breakers to prevent loops.
195
198
 
196
199
  A sandbox rejection is returned as an ordinary tool error. The extension does
197
200
  not replay a command that may have started. The model may issue a different
@@ -156,7 +156,10 @@ Guarantees supported: I5-I10, I14, I17.
156
156
  - Enforce one aggregate 90-second deadline and at most three retryable attempts.
157
157
  - Convert the structured decision call locally into the exact one-field verdict;
158
158
  never interpret free-form model text as a decision.
159
- - Treat every non-allow/failure as denial and return one fixed denial string.
159
+ - Block execution on every non-allow outcome, but report categories accurately:
160
+ only a valid `deny` decision is a permission denial; protocol/runtime failures
161
+ are review failures; user cancellation is `Operation aborted` and does not
162
+ increment denial breakers.
160
163
  - Track three consecutive and ten-of-fifty per-turn shell denial breakers;
161
164
  breaker interruption never blocks non-shell passthrough.
162
165
 
@@ -233,11 +233,13 @@ An Auto review admits an action only if all of the following hold:
233
233
  5. the current backend equals the captured backend;
234
234
  6. the current canonical request equals the reviewed canonical request.
235
235
 
236
- All other outcomes deny: a valid `deny` call, exhausted decision re-prompts,
237
- missing model, missing credentials, timeout, cancellation, queue exhaustion,
238
- oversized input, provider failure, internal exception, revision change, backend
239
- change, or request change. There is no fallback model and no conversion to
240
- Unrestricted.
236
+ All other outcomes block execution. A valid `deny` call is reported as a
237
+ permission denial. Exhausted decision re-prompts, missing model or credentials,
238
+ timeout, queue exhaustion, oversized input, provider failure, internal exception,
239
+ revision change, backend change, or request change are reported as review
240
+ failures. User cancellation is reported as `Operation aborted`, emits no denial
241
+ notification, and does not increment denial breakers. There is no fallback model
242
+ and no conversion to Unrestricted.
241
243
 
242
244
  The reviewer's complete semantic policy is: `Call deny only when the exact
243
245
  command is clearly likely to cause severe, difficult-to-reverse harm: broad loss
@@ -346,8 +348,9 @@ control-plane protection still operate, while every model-originated `bash` call
346
348
  infrastructure-specific safer-action result.
347
349
 
348
350
  If construction of the permission engine itself fails after subsystem startup,
349
- the extension publishes no active runtime, reports `Auto (unavailable)`, and its
350
- already-registered bash and non-bash gates return the fixed denial. Because the
351
+ the extension publishes no active runtime and reports `Auto (unavailable)`.
352
+ Read-only and custom tools still bypass the extension; bash and trusted standard
353
+ write/edit calls return an enforcement-failure result. Because the
351
354
  command host also requires an active runtime, the three extension commands
352
355
  report failure rather than mutating state in this condition. A later successful
353
356
  Pi session initialization (normally reload or process restart) is required to
@@ -435,7 +438,9 @@ API. Denial returns a fixed tool result to the model:
435
438
 
436
439
  User-facing selection UI exists only inside explicit user slash commands. A
437
440
  permission denial may additionally emit a passive warning notification naming
438
- the denied tool; that notification is not an approval request.
441
+ the denied tool; that notification is not an approval request. Review/runtime
442
+ failures use a distinct failure result and notification. User cancellation emits
443
+ neither and preserves the native `Operation aborted` result.
439
444
 
440
445
  ### I12. Direct-tool no-self-elevation invariant
441
446
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-auto-permissions",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A minimal Codex-style Auto/Unrestricted permission guardrail for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -34,6 +34,8 @@ export {
34
34
  GUARDIAN_DEFAULT_MAX_CONCURRENT_REVIEWS,
35
35
  GUARDIAN_DEFAULT_MAX_QUEUED_REVIEWS,
36
36
  GUARDIAN_DENIAL_MESSAGE,
37
+ GUARDIAN_OPERATION_ABORTED_MESSAGE,
38
+ GUARDIAN_REVIEW_FAILURE_MESSAGE,
37
39
  GUARDIAN_DECISION_TOOLS,
38
40
  GUARDIAN_INVESTIGATION_TOOLS,
39
41
  GUARDIAN_TOOLS,
@@ -27,6 +27,9 @@ import { GuardianVerdictError, parseGuardianVerdict } from "./verdict.js";
27
27
 
28
28
  export const GUARDIAN_DENIAL_MESSAGE =
29
29
  "Permission denied. This action was not executed. No override will be requested. Choose a materially safer action.";
30
+ export const GUARDIAN_REVIEW_FAILURE_MESSAGE =
31
+ "Permission review failed. This action was not executed.";
32
+ export const GUARDIAN_OPERATION_ABORTED_MESSAGE = "Operation aborted";
30
33
  export const GUARDIAN_REVIEW_DEADLINE_MS = 90_000;
31
34
  export const GUARDIAN_REVIEW_MAX_ATTEMPTS = 3;
32
35
  export const GUARDIAN_DEFAULT_MAX_CONCURRENT_REVIEWS = 4;
@@ -629,14 +632,21 @@ export class GuardianReviewEngine {
629
632
  record = true,
630
633
  verdict?: GuardianVerdict,
631
634
  ): GuardianDenyResult {
632
- const breaker = record
635
+ const shouldRecord = record && reason !== "cancelled";
636
+ const breaker = shouldRecord
633
637
  ? this.#circuitBreaker.recordDenial(turnId)
634
638
  : this.#circuitBreaker.snapshot(turnId);
639
+ const message =
640
+ reason === "cancelled"
641
+ ? GUARDIAN_OPERATION_ABORTED_MESSAGE
642
+ : reason === "model_denied" || reason === "circuit_breaker"
643
+ ? GUARDIAN_DENIAL_MESSAGE
644
+ : GUARDIAN_REVIEW_FAILURE_MESSAGE;
635
645
  const result = {
636
646
  outcome: "deny" as const,
637
647
  attempts,
638
648
  reason,
639
- message: GUARDIAN_DENIAL_MESSAGE,
649
+ message,
640
650
  interruptTurn: breaker.interruptTurn,
641
651
  };
642
652
  return verdict === undefined ? result : { ...result, verdict };
@@ -17,6 +17,7 @@ import {
17
17
  } from "../domain.ts";
18
18
  import {
19
19
  GUARDIAN_DENIAL_MESSAGE,
20
+ GUARDIAN_REVIEW_FAILURE_MESSAGE,
20
21
  GuardianReviewEngine,
21
22
  guardianReviewBindingsEqual,
22
23
  type GuardianDenialReason,
@@ -471,9 +472,15 @@ function admit(route: PermissionExecutionRoute, reviewed: boolean): PermissionAd
471
472
  }
472
473
 
473
474
  function deny(reason: PermissionDenyDecision["reason"]): PermissionDenyDecision {
475
+ const message =
476
+ reason === "configuration_fault" ||
477
+ reason === "sandbox_unavailable" ||
478
+ reason === "stale_binding"
479
+ ? GUARDIAN_REVIEW_FAILURE_MESSAGE
480
+ : GUARDIAN_DENIAL_MESSAGE;
474
481
  return {
475
482
  outcome: "deny",
476
- message: GUARDIAN_DENIAL_MESSAGE,
483
+ message,
477
484
  reason,
478
485
  interruptTurn: false,
479
486
  };
package/src/tools/bash.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { type Static, Type } from "typebox";
7
7
  import {
8
- GUARDIAN_DENIAL_MESSAGE,
8
+ GUARDIAN_REVIEW_FAILURE_MESSAGE,
9
9
  type GuardianTranscriptItem,
10
10
  } from "../guardian/index.ts";
11
11
  import type { PermissionEngine } from "../runtime/index.ts";
@@ -66,8 +66,8 @@ export function registerGuardedBashTool(
66
66
  async execute(toolCallId, params, signal, onUpdate, ctx) {
67
67
  const runtime = getRuntime();
68
68
  if (runtime === null) {
69
- notifyPermissionDenied(ctx, "bash");
70
- throw new Error(GUARDIAN_DENIAL_MESSAGE);
69
+ notifyPermissionDenied(ctx, "bash", "configuration_fault");
70
+ throw new Error(GUARDIAN_REVIEW_FAILURE_MESSAGE);
71
71
  }
72
72
  await runtime.refreshBackend(ctx);
73
73
  const reviewSignal = runtime.signal(signal);
@@ -83,8 +83,8 @@ export function registerGuardedBashTool(
83
83
  ...(reviewSignal === undefined ? {} : { signal: reviewSignal }),
84
84
  });
85
85
  if (decision.outcome === "deny") {
86
- notifyPermissionDenied(ctx, "bash", decision.reviewReason);
87
- if (decision.interruptTurn) ctx.abort();
86
+ notifyPermissionDenied(ctx, "bash", decision.reason, decision.reviewReason);
87
+ if (decision.reviewReason !== "cancelled" && decision.interruptTurn) ctx.abort();
88
88
  throw new Error(decision.message);
89
89
  }
90
90
 
@@ -7,13 +7,20 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  export function notifyPermissionDenied(
8
8
  ctx: Pick<ExtensionContext, "ui">,
9
9
  toolName: string,
10
+ reason: string = "invalid_action",
10
11
  reviewReason?: string,
11
12
  ): void {
12
13
  try {
14
+ if (reviewReason === "cancelled") return;
15
+ const explicitDenial =
16
+ reason === "invalid_action" ||
17
+ reviewReason === "model_denied" ||
18
+ reviewReason === "circuit_breaker";
19
+ const prefix = explicitDenial ? "Permission denied" : "Permission enforcement failed";
13
20
  const diagnostic = reviewReason === undefined ? "" : ` Guardian result: ${reviewReason}.`;
14
21
  ctx.ui.notify(
15
- `Permission denied: ${toolName} action was not executed.${diagnostic}`,
16
- "warning",
22
+ `${prefix}: ${toolName} action was not executed.${diagnostic}`,
23
+ explicitDenial ? "warning" : "error",
17
24
  );
18
25
  } catch {
19
26
  // A notification failure must not change the fixed fail-closed denial.
package/src/tools/gate.ts CHANGED
@@ -5,11 +5,12 @@ import type {
5
5
  ToolInfo,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { GuardianTranscriptItem } from "../guardian/index.ts";
8
- import { GUARDIAN_DENIAL_MESSAGE } from "../guardian/index.ts";
8
+ import { GUARDIAN_REVIEW_FAILURE_MESSAGE } from "../guardian/index.ts";
9
9
  import type { PermissionEngine } from "../runtime/index.ts";
10
10
  import { notifyPermissionDenied } from "./denial-notice.ts";
11
11
 
12
12
  const DIRECT_FILE_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "write", "edit"]);
13
+ const READ_ONLY_FILE_TOOL_NAMES = new Set(["read", "grep", "find", "ls"]);
13
14
 
14
15
  export interface PermissionToolGateRuntime {
15
16
  readonly engine: PermissionEngine;
@@ -26,14 +27,22 @@ export function registerPermissionToolGate(
26
27
  ): void {
27
28
  pi.on("tool_call", async (event, ctx) => {
28
29
  if (event.toolName === "bash") return;
30
+
31
+ const info = findToolInfo(pi, event.toolName);
32
+ const trustedFileTool = isTrustedStandardFileTool(info, event);
33
+ // Reads and explicitly installed custom tools are outside this extension's
34
+ // enforcement surface. Do not touch permission state, lifecycle, or abort
35
+ // signals for them; their native success/error/cancellation must pass through.
36
+ if (!trustedFileTool || READ_ONLY_FILE_TOOL_NAMES.has(event.toolName)) return;
37
+
38
+ // Only trusted standard write/edit calls reach deterministic path policy.
29
39
  const runtime = getRuntime();
30
40
  if (runtime === null) {
31
- notifyPermissionDenied(ctx, event.toolName);
32
- return { block: true, reason: GUARDIAN_DENIAL_MESSAGE };
41
+ notifyPermissionDenied(ctx, event.toolName, "configuration_fault");
42
+ return { block: true, reason: GUARDIAN_REVIEW_FAILURE_MESSAGE };
33
43
  }
34
44
 
35
45
  await runtime.refreshStatus(ctx);
36
- const info = findToolInfo(pi, event.toolName);
37
46
  const reviewSignal = runtime.signal(ctx.signal);
38
47
  const decision = await runtime.engine.gate({
39
48
  toolCallId: event.toolCallId,
@@ -42,12 +51,12 @@ export function registerPermissionToolGate(
42
51
  input: event.input,
43
52
  cwd: ctx.cwd,
44
53
  toolMetadata: canonicalToolMetadata(info, event.toolName),
45
- trustedFileTool: isTrustedStandardFileTool(info, event),
54
+ trustedFileTool,
46
55
  transcript: () => runtime.transcript(ctx),
47
56
  ...(reviewSignal === undefined ? {} : { signal: reviewSignal }),
48
57
  });
49
58
  if (decision.outcome === "admit") return;
50
- notifyPermissionDenied(ctx, event.toolName, decision.reviewReason);
59
+ notifyPermissionDenied(ctx, event.toolName, decision.reason, decision.reviewReason);
51
60
  if (decision.interruptTurn) ctx.abort();
52
61
  return { block: true, reason: decision.message };
53
62
  });