@andares/pi-toolkits 0.8.0 → 0.9.1
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 +72 -5
- package/README.zh-CN.md +71 -5
- package/package.json +7 -3
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/autostart/autostart.test.ts +113 -2
- package/src/features/autostart/autostart.ts +28 -2
- package/src/features/autostart/index.ts +138 -49
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
package/src/index.ts
CHANGED
|
@@ -11,21 +11,34 @@
|
|
|
11
11
|
* - review — third-party code review with a configured review model (third-review)
|
|
12
12
|
* - compact — dedicated compact model for /compact + auto-compact summaries
|
|
13
13
|
* - autostart — replay project-declared slash commands at session start (/add-dir persistence)
|
|
14
|
+
* - permission — session-scoped boundary permission checks (/ask-perm)
|
|
15
|
+
*
|
|
16
|
+
* `ask` and `permission` are independent gates on one bus: each answers its own
|
|
17
|
+
* question and the strictest answer wins, so they compose rather than replace
|
|
18
|
+
* each other. The bus is installed once, after every gate is registered.
|
|
14
19
|
*/
|
|
15
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { createGateHub } from "./lib/gates.js";
|
|
16
22
|
import { registerAsk } from "./features/ask/index.js";
|
|
17
23
|
import { registerAutostart } from "./features/autostart/index.js";
|
|
18
24
|
import { registerCompact } from "./features/compact/index.js";
|
|
19
25
|
import { registerFavorites } from "./features/favorites/index.js";
|
|
26
|
+
import { registerPermission } from "./features/permission/index.js";
|
|
20
27
|
import { registerReview } from "./features/review/index.js";
|
|
21
28
|
import { registerStash } from "./features/stash/index.js";
|
|
22
29
|
|
|
23
30
|
export default function piToolkits(pi: ExtensionAPI): void {
|
|
24
|
-
|
|
31
|
+
const gates = createGateHub();
|
|
32
|
+
|
|
33
|
+
registerAsk(pi, gates);
|
|
34
|
+
// Permission registers its session_start reset before autostart, so a
|
|
35
|
+
// project-declared `/ask-perm ...` runs against a freshly reset mode.
|
|
36
|
+
registerPermission(pi, gates);
|
|
25
37
|
registerAutostart(pi);
|
|
26
38
|
registerCompact(pi);
|
|
27
39
|
registerFavorites(pi);
|
|
28
40
|
registerReview(pi);
|
|
29
41
|
registerStash(pi);
|
|
30
|
-
|
|
42
|
+
|
|
43
|
+
gates.install(pi);
|
|
31
44
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate bus tests.
|
|
3
|
+
*
|
|
4
|
+
* The bus is the composition contract between features: a block short-circuits,
|
|
5
|
+
* an allow never overrides, dialogs are serialized, and a failing gate fails
|
|
6
|
+
* closed.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import type {
|
|
10
|
+
ExtensionContext,
|
|
11
|
+
ToolCallEvent,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
createGateHub,
|
|
15
|
+
type AskDecision,
|
|
16
|
+
type AskRequest,
|
|
17
|
+
type ToolGate,
|
|
18
|
+
} from "./gates.js";
|
|
19
|
+
|
|
20
|
+
function bashEvent(command = "ls"): ToolCallEvent {
|
|
21
|
+
return {
|
|
22
|
+
type: "tool_call",
|
|
23
|
+
toolCallId: "call-1",
|
|
24
|
+
toolName: "bash",
|
|
25
|
+
input: { command },
|
|
26
|
+
} as unknown as ToolCallEvent;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ctx = {} as ExtensionContext;
|
|
30
|
+
|
|
31
|
+
function askRequest(
|
|
32
|
+
decide: AskDecision | undefined,
|
|
33
|
+
applied: AskDecision[] = [],
|
|
34
|
+
): AskRequest {
|
|
35
|
+
return {
|
|
36
|
+
title: "Permission required",
|
|
37
|
+
details: ["Command: rm x"],
|
|
38
|
+
root: "/proj",
|
|
39
|
+
present: async () => decide,
|
|
40
|
+
apply: (choice) => applied.push(choice),
|
|
41
|
+
denyReason: (choice) => `denied:${choice}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function gate(
|
|
46
|
+
name: string,
|
|
47
|
+
order: number,
|
|
48
|
+
verdict: ReturnType<ToolGate["evaluate"]>,
|
|
49
|
+
): ToolGate {
|
|
50
|
+
return { name, order, evaluate: () => verdict };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("gate hub", () => {
|
|
54
|
+
it("allows when no gate has an opinion", async () => {
|
|
55
|
+
const hub = createGateHub();
|
|
56
|
+
hub.register(gate("noop", 10, undefined));
|
|
57
|
+
await expect(hub.handle(bashEvent(), ctx)).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("an explicit allow does not override another gate's block", async () => {
|
|
61
|
+
const hub = createGateHub();
|
|
62
|
+
hub.register(gate("allow", 10, { kind: "allow" }));
|
|
63
|
+
hub.register(gate("block", 20, { kind: "block", reason: "no" }));
|
|
64
|
+
await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({ reason: "no" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("blocks immediately and skips later gates", async () => {
|
|
68
|
+
const hub = createGateHub();
|
|
69
|
+
let laterRan = false;
|
|
70
|
+
hub.register(gate("first", 10, { kind: "block", reason: "stop" }));
|
|
71
|
+
hub.register({
|
|
72
|
+
name: "later",
|
|
73
|
+
order: 20,
|
|
74
|
+
evaluate: () => {
|
|
75
|
+
laterRan = true;
|
|
76
|
+
return undefined;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({ reason: "stop" });
|
|
80
|
+
expect(laterRan).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("runs gates in ascending order", async () => {
|
|
84
|
+
const hub = createGateHub();
|
|
85
|
+
const seen: string[] = [];
|
|
86
|
+
hub.register({
|
|
87
|
+
name: "second",
|
|
88
|
+
order: 20,
|
|
89
|
+
evaluate: () => {
|
|
90
|
+
seen.push("second");
|
|
91
|
+
return undefined;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
hub.register({
|
|
95
|
+
name: "first",
|
|
96
|
+
order: 10,
|
|
97
|
+
evaluate: () => {
|
|
98
|
+
seen.push("first");
|
|
99
|
+
return undefined;
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
await hub.handle(bashEvent(), ctx);
|
|
103
|
+
expect(seen).toEqual(["first", "second"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("applies an allow answer and resolves", async () => {
|
|
107
|
+
const hub = createGateHub();
|
|
108
|
+
const applied: AskDecision[] = [];
|
|
109
|
+
hub.register(gate("ask", 10, { kind: "ask", request: askRequest("allow", applied) }));
|
|
110
|
+
await expect(hub.handle(bashEvent(), ctx)).resolves.toBeUndefined();
|
|
111
|
+
expect(applied).toEqual(["allow"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("blocks with the request's deny reason", async () => {
|
|
115
|
+
const hub = createGateHub();
|
|
116
|
+
const applied: AskDecision[] = [];
|
|
117
|
+
hub.register(
|
|
118
|
+
gate("ask", 10, { kind: "ask", request: askRequest("deny-session", applied) }),
|
|
119
|
+
);
|
|
120
|
+
await expect(hub.handle(bashEvent(), ctx)).resolves.toEqual({
|
|
121
|
+
reason: "denied:deny-session",
|
|
122
|
+
});
|
|
123
|
+
expect(applied).toEqual(["deny-session"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("treats a cancelled or unanswerable dialog as a denial", async () => {
|
|
127
|
+
const hub = createGateHub();
|
|
128
|
+
hub.register(gate("ask", 10, { kind: "ask", request: askRequest(undefined) }));
|
|
129
|
+
const result = await hub.handle(bashEvent(), ctx);
|
|
130
|
+
expect(result?.reason).toContain("was not answered");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("fails closed when a gate throws", async () => {
|
|
134
|
+
const hub = createGateHub();
|
|
135
|
+
hub.register({
|
|
136
|
+
name: "broken",
|
|
137
|
+
order: 10,
|
|
138
|
+
evaluate: () => {
|
|
139
|
+
throw new Error("boom");
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const result = await hub.handle(bashEvent(), ctx);
|
|
143
|
+
expect(result?.reason).toContain("broken");
|
|
144
|
+
expect(result?.reason).toContain("blocked");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("serializes dialogs across concurrent tool calls", async () => {
|
|
148
|
+
const hub = createGateHub();
|
|
149
|
+
let active = 0;
|
|
150
|
+
let maxActive = 0;
|
|
151
|
+
const present = async (): Promise<AskDecision> => {
|
|
152
|
+
active++;
|
|
153
|
+
maxActive = Math.max(maxActive, active);
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
155
|
+
active--;
|
|
156
|
+
return "allow";
|
|
157
|
+
};
|
|
158
|
+
const request: AskRequest = {
|
|
159
|
+
title: "Permission required",
|
|
160
|
+
details: [],
|
|
161
|
+
root: "/proj",
|
|
162
|
+
present,
|
|
163
|
+
apply: () => {},
|
|
164
|
+
denyReason: () => "denied",
|
|
165
|
+
};
|
|
166
|
+
hub.register(gate("ask", 10, { kind: "ask", request }));
|
|
167
|
+
|
|
168
|
+
await Promise.all([hub.handle(bashEvent("a"), ctx), hub.handle(bashEvent("b"), ctx)]);
|
|
169
|
+
expect(maxActive).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/lib/gates.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call gate bus.
|
|
3
|
+
*
|
|
4
|
+
* Features that must veto or confirm tool calls (`ask`, `permission`) register
|
|
5
|
+
* a {@link ToolGate} instead of installing their own `tool_call` handler. One
|
|
6
|
+
* handler composes every gate, which keeps the features independent: each gate
|
|
7
|
+
* speaks only about its own concern, and the call is allowed only when no gate
|
|
8
|
+
* objects.
|
|
9
|
+
*
|
|
10
|
+
* Composition rules:
|
|
11
|
+
* - A `block` is final and short-circuits the remaining gates, so a call one
|
|
12
|
+
* feature already denies never raises another feature's dialog.
|
|
13
|
+
* - An `allow` never overrides another gate's `ask`/`block`; "no opinion" and
|
|
14
|
+
* "explicitly allowed" are both non-blocking.
|
|
15
|
+
* - `ask`s are presented through a single queue. Pi runs tool calls from one
|
|
16
|
+
* assistant message in parallel while `ctx.ui.custom` owns a single inline
|
|
17
|
+
* slot; a second presentation would mount over the first and strand its
|
|
18
|
+
* promise. The queue serializes our own presentations; the host slot is
|
|
19
|
+
* still shared with other extensions (pi does not arbitrate globally).
|
|
20
|
+
* - A gate that throws blocks the call (fail closed), matching pi's own
|
|
21
|
+
* `tool_call` handler contract.
|
|
22
|
+
*/
|
|
23
|
+
import type {
|
|
24
|
+
ExtensionAPI,
|
|
25
|
+
ExtensionContext,
|
|
26
|
+
ToolCallEvent,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
|
|
29
|
+
/** What the operator chose in an interactive ask. */
|
|
30
|
+
export type AskDecision =
|
|
31
|
+
| "allow"
|
|
32
|
+
| "allow-session"
|
|
33
|
+
| "deny"
|
|
34
|
+
| "deny-session";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One interactive confirmation a gate wants before the call may run.
|
|
38
|
+
*
|
|
39
|
+
* `present` renders the dialog (or falls back to a host selector) and resolves
|
|
40
|
+
* `undefined` when there is no way to ask or the operator cancelled — both are
|
|
41
|
+
* treated as a denial. `apply` records the decision (session grants) and runs
|
|
42
|
+
* only for a real answer.
|
|
43
|
+
*/
|
|
44
|
+
export interface AskRequest {
|
|
45
|
+
/** Short title, e.g. "Permission required". */
|
|
46
|
+
readonly title: string;
|
|
47
|
+
/** Context lines: command, target, scope, reason. */
|
|
48
|
+
readonly details: readonly string[];
|
|
49
|
+
/** The directory a session grant would cover. */
|
|
50
|
+
readonly root: string;
|
|
51
|
+
present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
|
|
52
|
+
apply(decision: AskDecision): void;
|
|
53
|
+
/** Model-facing reason when the operator answers deny/deny-session. */
|
|
54
|
+
denyReason(decision: AskDecision): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A gate's opinion about one tool call. */
|
|
58
|
+
export type GateVerdict =
|
|
59
|
+
| undefined
|
|
60
|
+
| { readonly kind: "allow" }
|
|
61
|
+
| { readonly kind: "block"; readonly reason: string }
|
|
62
|
+
| { readonly kind: "ask"; readonly request: AskRequest };
|
|
63
|
+
|
|
64
|
+
/** One feature's tool-call policy. Lower `order` runs first. */
|
|
65
|
+
export interface ToolGate {
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly order: number;
|
|
68
|
+
evaluate(
|
|
69
|
+
event: ToolCallEvent,
|
|
70
|
+
ctx: ExtensionContext,
|
|
71
|
+
): GateVerdict | Promise<GateVerdict>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Result of composing the gates: a block reason, or nothing (allow). */
|
|
75
|
+
export type GateResult = { readonly reason: string } | undefined;
|
|
76
|
+
|
|
77
|
+
export interface GateHub {
|
|
78
|
+
register(gate: ToolGate): void;
|
|
79
|
+
/** Compose every gate for one call (also the unit-test entry point). */
|
|
80
|
+
handle(event: ToolCallEvent, ctx: ExtensionContext): Promise<GateResult>;
|
|
81
|
+
/** Install the single `tool_call` handler that owns the gates. */
|
|
82
|
+
install(pi: ExtensionAPI): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function messageOf(error: unknown): string {
|
|
86
|
+
return error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createGateHub(): GateHub {
|
|
90
|
+
const gates: ToolGate[] = [];
|
|
91
|
+
let queue: Promise<void> = Promise.resolve();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Serialize dialogs without letting one rejection poison the queue:
|
|
95
|
+
* the tail always settles, the caller still sees its own outcome.
|
|
96
|
+
*/
|
|
97
|
+
function runExclusive<T>(present: () => Promise<T>): Promise<T> {
|
|
98
|
+
const next = queue.then(present, present);
|
|
99
|
+
queue = next.then(
|
|
100
|
+
() => undefined,
|
|
101
|
+
() => undefined,
|
|
102
|
+
);
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function handle(
|
|
107
|
+
event: ToolCallEvent,
|
|
108
|
+
ctx: ExtensionContext,
|
|
109
|
+
): Promise<GateResult> {
|
|
110
|
+
const pending: AskRequest[] = [];
|
|
111
|
+
for (const gate of [...gates].sort((a, b) => a.order - b.order)) {
|
|
112
|
+
let verdict: GateVerdict;
|
|
113
|
+
try {
|
|
114
|
+
verdict = await gate.evaluate(event, ctx);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
reason: `Permission check failed in "${gate.name}": ${messageOf(error)}. The call was blocked.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!verdict || verdict.kind === "allow") continue;
|
|
121
|
+
if (verdict.kind === "block") return { reason: verdict.reason };
|
|
122
|
+
pending.push(verdict.request);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const request of pending) {
|
|
126
|
+
const decision = await runExclusive(() => request.present(ctx));
|
|
127
|
+
if (decision === undefined) {
|
|
128
|
+
return {
|
|
129
|
+
reason: `Permission check "${request.title}" was not answered; the call was blocked.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
request.apply(decision);
|
|
133
|
+
if (decision === "deny" || decision === "deny-session") {
|
|
134
|
+
return { reason: request.denyReason(decision) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
register(gate) {
|
|
142
|
+
gates.push(gate);
|
|
143
|
+
},
|
|
144
|
+
handle,
|
|
145
|
+
install(pi) {
|
|
146
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
147
|
+
const result = await handle(event, ctx);
|
|
148
|
+
if (!result) return;
|
|
149
|
+
return { block: true, reason: result.reason };
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|