@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.
Files changed (3) hide show
  1. package/main.ts +304 -0
  2. package/package.json +21 -0
  3. 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
+ }
package/plugin.toml ADDED
@@ -0,0 +1,5 @@
1
+ name = "approvals"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "Asks before the agent does something the policy says to ask about."
5
+ permissions = ["hooks_blocking"]