@neosh/approvals 0.1.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/main.ts +304 -0
- package/package.json +21 -0
- package/plugin.toml +5 -0
package/main.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval prompt.
|
|
3
|
+
*
|
|
4
|
+
* Without something answering, `ask` means "refuse": every gated tool call comes back as an error
|
|
5
|
+
* the model cannot act on and the user never sees a question. This is the half that asks.
|
|
6
|
+
*
|
|
7
|
+
* `permissions.mode` starts at `allow` — see `PermissionsConfig::default` for why — so on a fresh
|
|
8
|
+
* install this hook never fires. That is not a reason for it to be quieter: the mode is a *per
|
|
9
|
+
* conversation* setting, saved with the conversation, so any one of them may be back in `ask` and
|
|
10
|
+
* the footer is what says which.
|
|
11
|
+
*
|
|
12
|
+
* It is a **blocking** hook, so the blocking-hook rule applies — a blocking hook that does not
|
|
13
|
+
* answer in time is a veto. A permission prompt that failed open would be worse than no prompt at
|
|
14
|
+
* all, so the timeout is generous enough for a human and the failure direction is "no".
|
|
15
|
+
*
|
|
16
|
+
* Remembering an answer is deliberately per-session and in memory. Writing "always allow" to disk
|
|
17
|
+
* is how a policy quietly becomes permanent, and the file to edit for that is `config.toml`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
Capability,
|
|
22
|
+
HookOutcome,
|
|
23
|
+
HookPayload,
|
|
24
|
+
Neosh,
|
|
25
|
+
PermissionMode,
|
|
26
|
+
PermissionOption,
|
|
27
|
+
PluginContext,
|
|
28
|
+
} from "@neosh/api";
|
|
29
|
+
import { picker } from "@neosh/api/ui";
|
|
30
|
+
|
|
31
|
+
/** How long the user has. Past this the hook times out, which the host reads as a refusal. */
|
|
32
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The conversations blocked on an approval, as a workspace var.
|
|
36
|
+
*
|
|
37
|
+
* The same shape and the same reasoning as `question.asking`: being blocked on a person
|
|
38
|
+
* is a fact about the *conversation*, not about whichever panel happened to draw the prompt, so it
|
|
39
|
+
* goes somewhere everything can read it. The host reads it to decide whether the person is worth
|
|
40
|
+
* interrupting for, and a panel that wants to mark the row can read the same thing
|
|
41
|
+
* without this plugin knowing it exists.
|
|
42
|
+
*/
|
|
43
|
+
const VAR_ASKING = "permission.asking";
|
|
44
|
+
|
|
45
|
+
/** One row of the prompt: what it says, and what answering it means. */
|
|
46
|
+
type Choice = {
|
|
47
|
+
label: string;
|
|
48
|
+
detail?: string;
|
|
49
|
+
/** An option the asker itself offered, handed back by id. */
|
|
50
|
+
value: { kind: "option"; id: string } | { kind: "once" } | { kind: "session" } | { kind: "no" };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
54
|
+
await neosh.opt.declare({
|
|
55
|
+
name: "approvals.remember",
|
|
56
|
+
type: { type: "bool" },
|
|
57
|
+
default: true,
|
|
58
|
+
description:
|
|
59
|
+
'Offer "allow for this session". The answer is held in memory only — making it permanent is a line in config.toml, not a keystroke.',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Capabilities allowed for the rest of this session, by their exact description. Keyed on the
|
|
63
|
+
// rendered description rather than the object so `read_file` of two different paths are two
|
|
64
|
+
// different decisions.
|
|
65
|
+
const allowed = new Set<string>();
|
|
66
|
+
|
|
67
|
+
// Which conversations are waiting on an answer, and how many prompts each has open. A count
|
|
68
|
+
// rather than a set, because two tool calls in one turn can both be blocked and answering the
|
|
69
|
+
// first must not say the conversation is free.
|
|
70
|
+
const waiting = new Map<string, number>();
|
|
71
|
+
/**
|
|
72
|
+
* `null` to start rather than an empty string, so the first call writes whatever the map is —
|
|
73
|
+
* including the empty one, which is how a var left behind by a workspace that stopped with a
|
|
74
|
+
* prompt open is cleared. See the questions plugin, which has the same shape for the same reason.
|
|
75
|
+
*/
|
|
76
|
+
let announced: string | null = null;
|
|
77
|
+
const announce = async () => {
|
|
78
|
+
const ids = [...waiting.entries()].filter(([, n]) => n > 0).map(([id]) => id).sort();
|
|
79
|
+
const next = ids.join("\u0000");
|
|
80
|
+
// Written only when the set actually changed. The host tells the difference between a newly
|
|
81
|
+
// blocked conversation and one that already was, and rewriting an unchanged list would make
|
|
82
|
+
// every answer look like a new question in the ones still open.
|
|
83
|
+
if (announced === next) return;
|
|
84
|
+
announced = next;
|
|
85
|
+
await (ids.length === 0
|
|
86
|
+
? neosh.vars.remove({ scope: "global" }, VAR_ASKING)
|
|
87
|
+
: neosh.vars.set({ scope: "global" }, VAR_ASKING, ids)).catch(() => {});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// What the agent may do without asking, in the footer, with the key that changes it. On screen
|
|
91
|
+
// the whole time, in every mode: it is a per-conversation setting now, so "what am I in" is a
|
|
92
|
+
// question with a different answer in the next conversation along, and a row that only appeared
|
|
93
|
+
// for the unusual values would be a row you had to remember the absence of.
|
|
94
|
+
const showMode = async () => {
|
|
95
|
+
const mode = await neosh.permission.mode().catch(() => null);
|
|
96
|
+
if (!mode) return;
|
|
97
|
+
await neosh.status.set("mode", {
|
|
98
|
+
text: label(mode),
|
|
99
|
+
keys: "⇧⇥",
|
|
100
|
+
// Not a warning any more, because it is the state you are in by default and a footer that
|
|
101
|
+
// cries wolf on every conversation is a footer nobody reads. Still its own colour, so the
|
|
102
|
+
// row answers "what am I in" at a glance rather than by being read.
|
|
103
|
+
hl: mode === "allow" ? "Agent.ToolEdit" : mode === "deny" ? "Comment" : "Status.Line",
|
|
104
|
+
priority: 5,
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
// Switching conversations changes the mode, because the mode belongs to the conversation. The
|
|
108
|
+
// footer has to be told, or it keeps saying what the conversation you just left was set to —
|
|
109
|
+
// which is the one kind of wrong this row must never be.
|
|
110
|
+
subscriptions.push(neosh.session.onChange(() => {
|
|
111
|
+
allowed.clear();
|
|
112
|
+
void showMode();
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
await neosh.cmd.register("permission.cycle", async () => {
|
|
116
|
+
const order: PermissionMode[] = ["ask", "allow_listed", "allow", "deny"];
|
|
117
|
+
const now = await neosh.permission.mode().catch(() => "ask" as PermissionMode);
|
|
118
|
+
const next = order[(order.indexOf(now) + 1) % order.length] ?? "ask";
|
|
119
|
+
await neosh.permission.setMode(next);
|
|
120
|
+
await showMode();
|
|
121
|
+
neosh.notify(`permissions: ${label(next)}`, "info");
|
|
122
|
+
}, { desc: "Cycle what the agent may do without asking" });
|
|
123
|
+
await neosh.cmd.register("permission.pick", () => pickMode(neosh, showMode), {
|
|
124
|
+
desc: "Choose what the agent may do without asking",
|
|
125
|
+
});
|
|
126
|
+
// Shift-Tab, which is what codex uses for the same idea and what people's fingers already do.
|
|
127
|
+
// Not a control key: every free one is one somebody's `init.ts` has taken, and this is the sort
|
|
128
|
+
// of binding that should not need arguing about.
|
|
129
|
+
await neosh.keymap.set("chat", "<S-Tab>", "permission.pick", { desc: "Permission mode" });
|
|
130
|
+
await showMode();
|
|
131
|
+
|
|
132
|
+
subscriptions.push(
|
|
133
|
+
await neosh.hook.register(
|
|
134
|
+
"permission_pre",
|
|
135
|
+
async (payload): Promise<HookOutcome> => {
|
|
136
|
+
if (payload.hook !== "permission_pre") return { action: "continue" };
|
|
137
|
+
// The asker's own sentence when there is one. An agent driver writes a better line than
|
|
138
|
+
// anything reconstructible from the capability — it knows what it is doing and why.
|
|
139
|
+
const key = payload.title ?? describe(payload.capability);
|
|
140
|
+
|
|
141
|
+
if (allowed.has(key)) return { action: "continue" };
|
|
142
|
+
|
|
143
|
+
const remember = (await neosh.opt.get<boolean>("approvals.remember")) ?? true;
|
|
144
|
+
const choices = offered(payload.options, remember);
|
|
145
|
+
|
|
146
|
+
// Said before the picker opens and taken back in `finally`, so a prompt that is escaped,
|
|
147
|
+
// times out or throws does not leave a conversation marked as waiting forever.
|
|
148
|
+
const asking = payload.session;
|
|
149
|
+
if (asking) {
|
|
150
|
+
waiting.set(asking, (waiting.get(asking) ?? 0) + 1);
|
|
151
|
+
await announce();
|
|
152
|
+
}
|
|
153
|
+
let answer: Choice["value"] | null;
|
|
154
|
+
try {
|
|
155
|
+
answer = await picker(neosh, choices, {
|
|
156
|
+
title: key,
|
|
157
|
+
width: Math.max(40, Math.min(88, key.length + 8)),
|
|
158
|
+
height: choices.length,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
if (asking) {
|
|
162
|
+
waiting.set(asking, Math.max(0, (waiting.get(asking) ?? 1) - 1));
|
|
163
|
+
await announce();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Dismissing is a no. A prompt you can escape into an allow is not a prompt.
|
|
168
|
+
if (answer === null) return { action: "veto", reason: "not approved" };
|
|
169
|
+
switch (answer.kind) {
|
|
170
|
+
case "session":
|
|
171
|
+
allowed.add(key);
|
|
172
|
+
return { action: "continue" };
|
|
173
|
+
case "once":
|
|
174
|
+
return { action: "continue" };
|
|
175
|
+
case "no":
|
|
176
|
+
return { action: "veto", reason: "denied" };
|
|
177
|
+
case "option": {
|
|
178
|
+
// Back through the payload, because "allow, and stop asking about `cargo`" is a thing
|
|
179
|
+
// only the agent that offered it can act on — a bare yes would throw that away.
|
|
180
|
+
const chosen: HookPayload = { ...payload, chosen: answer.id };
|
|
181
|
+
const taken = payload.options.find((o) => o.id === answer.id);
|
|
182
|
+
return taken && !allows(taken)
|
|
183
|
+
? { action: "veto", reason: taken.label }
|
|
184
|
+
: { action: "modify", payload: chosen };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
{ blocking: true, timeoutMs: ASK_TIMEOUT_MS },
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Once, empty, at startup. The queue is in memory and the var is on disk, so a workspace that
|
|
193
|
+
// stopped with a prompt open left a conversation marked as waiting on an answer nothing is
|
|
194
|
+
// going to ask for again — and this is what takes the mark off. Same reasoning as the questions
|
|
195
|
+
// plugin's own announcement.
|
|
196
|
+
await announce();
|
|
197
|
+
|
|
198
|
+
subscriptions.push({
|
|
199
|
+
dispose: () => {
|
|
200
|
+
// Unloading this plugin vetoes every prompt it was holding, so a conversation still marked
|
|
201
|
+
// as waiting would be one nothing is ever going to ask about again.
|
|
202
|
+
void neosh.vars.remove({ scope: "global" }, VAR_ASKING).catch(() => {});
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Whether an option is one of the ways of saying yes. */
|
|
208
|
+
function allows(o: PermissionOption): boolean {
|
|
209
|
+
return o.kind === "allow_once" || o.kind === "allow_always";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The rows to show, given what the asker offered.
|
|
214
|
+
*
|
|
215
|
+
* When it offered nothing — every built-in tool — the question is yes or no and these are neosh's
|
|
216
|
+
* own three answers. When it offered something, those are the rows: an agent that can say "allow,
|
|
217
|
+
* and don't ask again for this command" is offering something neosh cannot reproduce from a yes,
|
|
218
|
+
* and replacing its wording with a generic one would be answering a different question.
|
|
219
|
+
*
|
|
220
|
+
* "Allow for this session" rides along either way. It is neosh's, not the agent's, and it is the
|
|
221
|
+
* one answer that holds across a driver that forgets everything between turns.
|
|
222
|
+
*/
|
|
223
|
+
function offered(options: PermissionOption[], remember: boolean): Choice[] {
|
|
224
|
+
const session: Choice[] = remember
|
|
225
|
+
? [{
|
|
226
|
+
label: "Allow for this session",
|
|
227
|
+
detail: "forgotten when you switch conversation",
|
|
228
|
+
value: { kind: "session" },
|
|
229
|
+
}]
|
|
230
|
+
: [];
|
|
231
|
+
if (options.length === 0) {
|
|
232
|
+
return [
|
|
233
|
+
{ label: "Allow once", value: { kind: "once" } },
|
|
234
|
+
...session,
|
|
235
|
+
{ label: "Deny", value: { kind: "no" } },
|
|
236
|
+
];
|
|
237
|
+
}
|
|
238
|
+
// The agent's order, with the allows first: a list that opens on "reject always" is a list where
|
|
239
|
+
// the fast answer is the destructive one.
|
|
240
|
+
const rows = [...options].sort((a, b) => Number(allows(b)) - Number(allows(a)));
|
|
241
|
+
const yes = rows.findIndex(allows);
|
|
242
|
+
const out: Choice[] = rows.map((o) => ({
|
|
243
|
+
label: o.label,
|
|
244
|
+
value: { kind: "option", id: o.id },
|
|
245
|
+
}));
|
|
246
|
+
// After the last "allow", so the session answer sits with the other ways of saying yes.
|
|
247
|
+
const at = yes < 0 ? out.length : rows.filter(allows).length;
|
|
248
|
+
out.splice(at, 0, ...session);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The word for a mode, as it reads in the footer. */
|
|
253
|
+
function label(mode: PermissionMode): string {
|
|
254
|
+
switch (mode) {
|
|
255
|
+
case "allow": return "full access";
|
|
256
|
+
case "allow_listed": return "allow-listed";
|
|
257
|
+
case "deny": return "deny";
|
|
258
|
+
default: return "ask";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Choose a mode from a list rather than cycling blindly past the dangerous one.
|
|
264
|
+
*
|
|
265
|
+
* Cycling is bound too, for people who know the order — but the default key opens this, because
|
|
266
|
+
* "full access" is one keystroke away from "ask" in any cycle, and arriving there by holding a key
|
|
267
|
+
* down is precisely the accident worth designing against.
|
|
268
|
+
*/
|
|
269
|
+
async function pickMode(neosh: Neosh, after: () => Promise<void>): Promise<void> {
|
|
270
|
+
const now = await neosh.permission.mode().catch(() => "ask" as PermissionMode);
|
|
271
|
+
const rows: Array<{ label: string; detail: string; value: PermissionMode }> = [
|
|
272
|
+
{ label: "Ask", detail: "prompt before writing, running or connecting", value: "ask" },
|
|
273
|
+
{ label: "Allow-listed", detail: "only what config.toml already permits", value: "allow_listed" },
|
|
274
|
+
{
|
|
275
|
+
label: "Full access",
|
|
276
|
+
detail: "no prompts — still confined to this workspace",
|
|
277
|
+
value: "allow",
|
|
278
|
+
},
|
|
279
|
+
{ label: "Deny", detail: "refuse everything; read-only", value: "deny" },
|
|
280
|
+
];
|
|
281
|
+
const chosen = await picker(neosh, rows, {
|
|
282
|
+
title: "Permissions",
|
|
283
|
+
width: 62,
|
|
284
|
+
height: rows.length,
|
|
285
|
+
selected: Math.max(0, rows.findIndex((r) => r.value === now)),
|
|
286
|
+
});
|
|
287
|
+
if (!chosen || chosen === now) return;
|
|
288
|
+
await neosh.permission.setMode(chosen);
|
|
289
|
+
await after();
|
|
290
|
+
neosh.notify(`permissions: ${label(chosen)}`, "info");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** What the user is actually being asked, in one line. */
|
|
294
|
+
function describe(c: Capability): string {
|
|
295
|
+
switch (c.kind) {
|
|
296
|
+
case "read_file": return `Read ${c.path}?`;
|
|
297
|
+
case "write_file": return `Write ${c.path}?`;
|
|
298
|
+
case "exec": return `Run ${c.command}?`;
|
|
299
|
+
case "network": return `Connect to ${c.host}?`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Re-exported so the type-checker keeps the hook signature honest. */
|
|
304
|
+
export type { Neosh };
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosh/approvals",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Asks before the agent does something the policy says to ask about.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"neosh",
|
|
9
|
+
"neosh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/neoswarm/neosh.git",
|
|
14
|
+
"directory": "plugins/builtin/approvals"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"plugin.toml",
|
|
19
|
+
"!._*"
|
|
20
|
+
]
|
|
21
|
+
}
|