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 +11 -8
- package/docs/implementation-plan.md +4 -1
- package/docs/invariants.md +13 -8
- package/package.json +1 -1
- package/src/guardian/index.ts +2 -0
- package/src/guardian/reviewer.ts +12 -2
- package/src/runtime/permission-engine.ts +8 -1
- package/src/tools/bash.ts +5 -5
- package/src/tools/denial-notice.ts +9 -2
- package/src/tools/gate.ts +15 -6
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/invariants.md
CHANGED
|
@@ -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
|
|
237
|
-
|
|
238
|
-
oversized input, provider failure, internal exception,
|
|
239
|
-
change, or request change
|
|
240
|
-
|
|
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
|
|
350
|
-
|
|
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
package/src/guardian/index.ts
CHANGED
|
@@ -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,
|
package/src/guardian/reviewer.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
});
|