@assuredloop/orca-bot-kit 0.25.6 → 0.26.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/src/launch.js CHANGED
@@ -40,6 +40,31 @@ export const bypassFlags = (harness) => APPROVAL[harness]['dangerously-skip'];
40
40
 
41
41
  export const HARNESSES = Object.keys(APPROVAL);
42
42
 
43
+ /** How wide a level is: `ask` is narrower than `auto`, and `auto` than `dangerously-skip`. */
44
+ export const approvalRank = (level) => ['ask', 'auto', 'dangerously-skip'].indexOf(level);
45
+
46
+ /**
47
+ * The harness flags that set approval or permissions, which only
48
+ * `obk permission approval` may change, not an extra argument (ADR 0041).
49
+ * Codex's `-a` and `-s` take their value glued on too.
50
+ */
51
+ const APPROVAL_FLAGS = {
52
+ claude: ['--permission-mode', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--allowedTools', '--allowed-tools'],
53
+ codex: ['-a', '--ask-for-approval', '--approve-for-me', '--dangerously-bypass-approvals-and-sandbox', '-s', '--sandbox'],
54
+ };
55
+
56
+ /**
57
+ * Refuse extra arguments that set a session's approval or permissions on
58
+ * `harness`, before anything is written, naming the command that does it.
59
+ */
60
+ export function refuseApprovalArgs(harness, args) {
61
+ const found = (args ?? []).find((arg) => (APPROVAL_FLAGS[harness] ?? []).some((flag) => arg === flag
62
+ || arg.startsWith(`${flag}=`)
63
+ || (/^-[as]$/.test(flag) && arg.startsWith(flag) && !arg.startsWith('--'))));
64
+ if (found === undefined) return;
65
+ throw new Error(`--extra-arg=${found} sets the session's approval or permissions on ${harness}, and only obk permission approval changes those, after the user's yes. Nothing was changed.`);
66
+ }
67
+
43
68
  /** The level a session that names none runs at (ADR 0015). */
44
69
  export const DEFAULT_APPROVAL = 'auto';
45
70
 
@@ -1,28 +1,30 @@
1
1
  // The permission rules a bot may run with, written into its own Claude
2
- // settings, and only the ones its user said yes to (#344).
2
+ // settings: the kit's default set, and the rules its user said yes to (#344).
3
3
  //
4
4
  // A bot in auto mode is stopped by the harness's check for things the kit's own
5
- // rules tell every bot to do: read and send its mail, read a long message's
5
+ // rules tell every bot to do: run the kit's commands, read a long message's
6
6
  // body beside the bots folder, commit. A matching allow rule is settled before
7
- // that check (Claude Code's permissions docs), so the kit offers every Claude
8
- // bot a small default set, spelled with its real CLI and bots folder. The user
9
- // sees the exact rules and says yes; `bot change --allow` keeps that yes in
10
- // bot.yaml's `allow`, and the kit's code writes what `allow` holds into
11
- // `.claude/settings.json` in the bot folder, beside its hook (ADR 0022). A model
12
- // editing the file by hand may get the format wrong; code does not.
7
+ // that check (Claude Code's permissions docs), so the kit gives every bot a
8
+ // default set, spelled with its real CLI and bots folder, with nobody asked
9
+ // (#527, ADR 0036). A rule beyond it is the user's yes, which
10
+ // `permission allow` keeps in bot.yaml's `allow`. The kit's code writes what
11
+ // `allow` holds into `.claude/settings.json` in the bot folder, beside its hook
12
+ // (ADR 0022). A model editing the file by hand may get the format wrong; code
13
+ // does not.
13
14
  //
14
15
  // The kit owns only the entries `allow` holds. Any other entry in the file is
15
16
  // left where it is, and health names it. A rule the user says yes to taking
16
- // back leaves `allow` and the files through `bot change --disallow` (#360).
17
+ // back leaves `allow` and the files through `permission disallow` (#360).
17
18
  //
18
- // A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
19
+ // A bot on Codex gets the same rules in Codex's own form (#354, ADR 0039): each
19
20
  // `Bash(<words>:*)` becomes a `prefix_rule` of those words in
20
21
  // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
21
22
 
22
23
  import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
23
24
  import path from 'node:path';
24
25
 
25
- import { leadsOutside } from './bot.js';
26
+ import { allowRules, leadsOutside } from './bot.js';
27
+ import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT } from './commands.js';
26
28
  import { readSettings } from './hooks.js';
27
29
  import { harnessOf, ownCli, shellWord } from './launch.js';
28
30
 
@@ -34,24 +36,36 @@ const CODEX_RULES = '.codex/rules';
34
36
  const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
35
37
 
36
38
  /**
37
- * The rules every Claude bot is offered, in this order. The mail commands are
38
- * narrowed to this bots folder and spelled as the kit prints them in its
39
- * nudge and in `message to`'s answer, which is what a bot runs. `//` is Claude's
40
- * form for an absolute path.
39
+ * The rules every bot is given (ADR 0036): each of the kit's default commands,
40
+ * and none kept back for the user's yes (ADR 0041), narrowed to this bots folder and
41
+ * spelled as the kit prints them, which is what a bot runs; reading a long
42
+ * message; committing; and the check Orca's mail notice tells a session to
43
+ * run. `//` is Claude's form for an absolute path.
41
44
  */
42
45
  export function defaultRules(bots, cli = ownCli()) {
43
46
  const kit = shellWord(cli);
44
47
  const folder = shellWord(bots);
45
48
  return [
46
- `Bash(${kit} message check --bots ${folder}:*)`,
47
- `Bash(${kit} message send --bots ${folder}:*)`,
48
- `Bash(${kit} message to --bots ${folder}:*)`,
49
+ ...DEFAULT_COMMANDS.map((command) => `Bash(${kit} ${command} --bots ${folder}:*)`),
49
50
  `Read(/${bots}.messages/**)`,
50
51
  'Bash(git add:*)',
51
52
  'Bash(git commit:*)',
53
+ 'Bash(orca orchestration check --run:*)',
52
54
  ];
53
55
  }
54
56
 
57
+ /**
58
+ * The rules of the commands that were defaults once and are kept back now
59
+ * (#548), spelled as `defaultRules` spelled them, that the bot's `allow`
60
+ * holds. The kit cannot tell its own old entry from a user's yes to the same
61
+ * rule, so it names them and leaves them (ADR 0041).
62
+ */
63
+ export function heldRules(bots, home, bot, cli = ownCli()) {
64
+ const allowed = allowOf(home, bot);
65
+ return NO_LONGER_DEFAULT.map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
66
+ .filter((rule) => allowed.includes(rule));
67
+ }
68
+
55
69
  /** Command wrappers: programs that run the command their arguments name. */
56
70
  const WRAPPERS = new Set(['env', 'xargs', 'sudo', 'eval', 'exec', 'nohup', 'timeout', 'nice', 'command', 'time', 'watch']);
57
71
 
@@ -73,7 +87,7 @@ const ANY = 'it lets the bot run any command';
73
87
  * Why a rule is broad, as the end of a sentence, or undefined for a narrow one
74
88
  * (#353). A charter's grants are written as narrow, exact rules only; one that
75
89
  * lets the bot run any command, a program with any arguments, or any file on
76
- * the disk or in the home is the user's to add by hand (ADR 0027).
90
+ * the disk or in the home is the user's to add by hand (ADR 0038).
77
91
  */
78
92
  export function broadness(rule) {
79
93
  if (typeof rule !== 'string') return undefined;
@@ -163,14 +177,14 @@ function commandBroadness(all) {
163
177
  }
164
178
 
165
179
  /**
166
- * Refuse the rules `--allow` was given when any is broad, before anything is
180
+ * Refuse the rules `permission allow` was given when any is broad, before anything is
167
181
  * written, naming the file the user can add it to themselves.
168
182
  */
169
183
  export function refuseBroad(home, rules) {
170
184
  for (const rule of rules) {
171
185
  const why = broadness(rule);
172
186
  if (why === undefined) continue;
173
- throw new Error(`--allow ${rule} is broad: ${why}. The kit writes only narrow, exact rules, such as Bash(gh pr merge:*), so nothing was written. If the user wants this rule for ${path.basename(home)}, they add it to ${path.join(home, FILE)} themselves.`);
187
+ throw new Error(`permission allow ${rule} is broad: ${why}. The kit writes only narrow, exact rules, such as Bash(gh pr merge:*), so nothing was written. If the user wants this rule for ${path.basename(home)}, they add it to ${path.join(home, FILE)} themselves.`);
174
188
  }
175
189
  }
176
190
 
@@ -200,14 +214,14 @@ export const runsOnCodex = (bot) => bot.harness === 'codex'
200
214
  || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
201
215
 
202
216
  /**
203
- * The default rules this bot has not been allowed yet for one harness's file,
204
- * in the default order. Codex is not offered the Read rule: its sandbox reads
205
- * every file already.
217
+ * The default rules this bot is not allowed yet, in the default order. A bot
218
+ * only on Codex is not given the Read rule: its sandbox reads every file
219
+ * already.
206
220
  */
207
- function waitingFor(bots, home, bot, harness) {
221
+ function missingDefaults(bots, home, bot) {
208
222
  const allowed = allowOf(home, bot);
209
223
  return defaultRules(bots)
210
- .filter((rule) => harness === 'claude' || codexForm(rule).line !== undefined)
224
+ .filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined)
211
225
  .filter((rule) => !allowed.includes(rule));
212
226
  }
213
227
 
@@ -242,7 +256,7 @@ export function codexForm(rule) {
242
256
  }
243
257
 
244
258
  /**
245
- * Refuse the rules `--allow` was given for a bot that runs only on Codex when
259
+ * Refuse the rules `permission allow` was given for a bot that runs only on Codex when
246
260
  * Codex has no form for one, before anything is written: recorded, it would
247
261
  * let the bot do nothing.
248
262
  */
@@ -251,35 +265,43 @@ export function refuseNoCodexForm(bot, rules) {
251
265
  for (const rule of rules) {
252
266
  const { why } = codexForm(rule);
253
267
  if (why === undefined) continue;
254
- throw new Error(`--allow ${rule} is not for ${bot.name}: ${why}, and ${bot.name} runs only on Codex, so nothing was written.`);
268
+ throw new Error(`permission allow ${rule} is not for ${bot.name}: ${why}, and ${bot.name} runs only on Codex, so nothing was written.`);
255
269
  }
256
270
  }
257
271
 
258
- /** The one command that allows `rules` for `bot`, as the user can run it. */
259
- export const allowCommand = (bots, bot, rules) => [
260
- shellWord(ownCli()), 'bot', 'change', '--bots', shellWord(bots), '--bot', shellWord(bot),
261
- ...rules.flatMap((rule) => ['--allow', shellWord(rule)]),
262
- ].join(' ');
263
-
264
272
  /**
265
- * Write what the bot is allowed into the files of the harnesses it runs on, and
266
- * say what waits: one entry per file, Claude's first. With `keepGoing`, a file
267
- * the kit may not write is an entry with its `trouble` rather than a throw.
273
+ * Add the default rules the bot is not allowed yet to its `allow` (ADR 0036),
274
+ * then write what it is allowed into the files of the harnesses it runs on:
275
+ * one entry per file, Claude's first, each with `defaults`, the rules this run
276
+ * added, and `held`, the rules of commands no longer in the set that the bot
277
+ * still holds (#548). The harness files are written first and bot.yaml last, as
278
+ * `permission allow` writes them (#383). With `keepGoing`, a file the kit may
279
+ * not write is an entry with its `trouble` rather than a throw.
268
280
  */
269
281
  export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
270
- const writers = [[runsOnClaude, FILE, planClaude], [runsOnCodex, CODEX_FILE, planCodex]];
271
- return writers.filter(([runs]) => runs(bot)).map(([, file, plan]) => {
272
- try {
273
- return plan(bots, home, bot).write();
274
- } catch (error) {
275
- if (!keepGoing) throw error;
276
- return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
282
+ const writers = [[runsOnClaude, FILE], [runsOnCodex, CODEX_FILE]].filter(([runs]) => runs(bot));
283
+ let defaults = [];
284
+ // Read before anything is written, so a file's trouble still names them.
285
+ let held = [];
286
+ try {
287
+ held = heldRules(bots, home, bot);
288
+ defaults = writers.length === 0 ? [] : missingDefaults(bots, home, bot);
289
+ if (defaults.length > 0) {
290
+ const { allow } = allowRules(bots, bot.name, defaults, { write: false });
291
+ const entries = allowIn(bots, home, { ...bot, allow })();
292
+ allowRules(bots, bot.name, defaults);
293
+ return entries.map((entry) => ({ ...entry, defaults, held }));
277
294
  }
278
- });
295
+ return writers.map(([runs]) => (runs === runsOnClaude ? planClaude : planCodex)(bots, home, bot).write())
296
+ .map((entry) => ({ ...entry, defaults, held }));
297
+ } catch (error) {
298
+ if (!keepGoing) throw error;
299
+ return writers.map(([, file]) => ({ bot: bot.name, file: path.join(home, file), written: [], defaults: [], held, trouble: error.message }));
300
+ }
279
301
  }
280
302
 
281
303
  /**
282
- * Check every file `bot change --allow` writes before any is written (#383):
304
+ * Check every file `permission allow` writes before any is written (#383):
283
305
  * bot.yaml, and the files of the harnesses the bot runs on, read and found
284
306
  * writable. `bot` is the bot as it will be, with the new rules in `allow`.
285
307
  * What comes back writes the harness files, one entry per file, Claude's
@@ -297,7 +319,7 @@ export function allowIn(bots, home, bot) {
297
319
  /**
298
320
  * What writing the bot's allowed rules into its Claude settings would do,
299
321
  * read and refused before anything is written: `{ answer, writes, write }`.
300
- * `write()` gives `{ bot, file, written, waiting }`, with `written` the rules
322
+ * `write()` gives `{ bot, file, written }`, with `written` the rules
301
323
  * it added.
302
324
  *
303
325
  * Only rules `allow` holds are written. Everything else in the file stays as it
@@ -307,8 +329,7 @@ export function allowIn(bots, home, bot) {
307
329
  function planClaude(bots, home, bot) {
308
330
  const file = path.join(home, FILE);
309
331
  const allowed = allowOf(home, bot);
310
- const waiting = waitingFor(bots, home, bot, 'claude');
311
- const answer = { bot: bot.name, file, written: [], waiting };
332
+ const answer = { bot: bot.name, file, written: [] };
312
333
  const none = { answer, writes: false, write: () => answer };
313
334
  if (allowed.length === 0) return none;
314
335
 
@@ -335,7 +356,7 @@ function planClaude(bots, home, bot) {
335
356
  * harnesses the bot runs on (#360): from its Claude settings every entry of
336
357
  * that exact text and nothing else, and its Codex rules rewritten from what
337
358
  * `allow` still holds. Only rules `allow` holds are taken back; one it does not
338
- * hold is refused, since the kit did not write it (ADR 0029).
359
+ * hold is refused, since the kit did not write it (ADR 0040).
339
360
  *
340
361
  * Everything is checked before anything is written, a file the kit could not
341
362
  * write included, bot.yaml too. What comes back writes the files: one entry per file,
@@ -347,6 +368,11 @@ export function takeBack(bots, home, bot, rules) {
347
368
  const allowed = allowOf(home, bot);
348
369
  const foreign = rules.find((rule) => !allowed.includes(rule));
349
370
  if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
371
+ // The next rules write would add it again (ADR 0040).
372
+ const kits = rules.find((rule) => defaultRules(bots).includes(rule));
373
+ if (kits !== undefined) {
374
+ throw new Error(`permission disallow ${kits} is one of the kit's default rules, which every bot has (#527). The kit writes it again at its next rules write, so it is not taken back, and nothing was changed.`);
375
+ }
350
376
  const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
351
377
  const taken = [...new Set(rules)];
352
378
  // bot.yaml is written last, and a harness file taken out of step with it
@@ -380,7 +406,7 @@ export function takeBack(bots, home, bot, rules) {
380
406
  const allow = present.filter((rule) => !removed.includes(rule));
381
407
  writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
382
408
  }
383
- entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
409
+ entries.push({ bot: bot.name, file, written: [], removed });
384
410
  }
385
411
  if (runsOnCodex(after)) {
386
412
  const entry = planCodex(bots, home, after).write();
@@ -413,7 +439,7 @@ function refuseUnwritable(file) {
413
439
  }
414
440
  }
415
441
 
416
- /** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
442
+ /** Why `permission disallow` refuses a rule `allow` does not hold, and where one of that text is. */
417
443
  function notAllowed(home, bot, rule) {
418
444
  const yaml = path.join(home, 'bot.yaml');
419
445
  const file = path.join(home, FILE);
@@ -427,7 +453,7 @@ function notAllowed(home, bot, rule) {
427
453
  const where = byHand
428
454
  ? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
429
455
  : '';
430
- return `--disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
456
+ return `permission disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
431
457
  }
432
458
 
433
459
  /**
@@ -452,14 +478,14 @@ const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((
452
478
  /**
453
479
  * What writing the Codex form of what the bot is allowed into
454
480
  * `.codex/rules/obk.rules` would do, as `planClaude` gives it. `write()` gives
455
- * `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
481
+ * `{ bot, file, written, unwritten }`, with `written` every rule in
456
482
  * the file when it wrote it. The kit owns the file whole and writes it only
457
483
  * when it would change; with nothing to write and no file, it makes none.
458
484
  */
459
485
  function planCodex(bots, home, bot) {
460
486
  const file = path.join(home, CODEX_FILE);
461
487
  const { lines, unwritten } = codexOf(home, bot);
462
- const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
488
+ const answer = { bot: bot.name, file, written: [], unwritten };
463
489
  const none = { answer, writes: false, write: () => answer };
464
490
  if (lines.size === 0 && !existsSync(file)) return none;
465
491
 
@@ -502,11 +528,13 @@ export function permissionsTrouble(home, bot) {
502
528
  function claudeTrouble(home, bot, allowed) {
503
529
  if (!runsOnClaude(bot)) return [];
504
530
  const file = path.join(home, FILE);
505
- if (!existsSync(file) && allowed.length === 0) return [];
506
-
507
- // A link out of the bot folder is named by the hook's check already; here it
508
- // matters only when there are rules the kit would write through it.
509
- if (allowed.length === 0 && leadsOutside(home, file) !== undefined) return [];
531
+ // A file that is not there, or a link out of the bot folder, is named by the
532
+ // hook's check already, which reads this file for every bot with a Claude
533
+ // session, and `obk up` puts back the hook and the rules together. Here it
534
+ // matters only for a bot without one, when there are rules to write.
535
+ const hookNamesIt = bot.sessions.some((session) => harnessOf(session, bot.harness) === 'claude');
536
+ if (!existsSync(file) && (allowed.length === 0 || hookNamesIt)) return [];
537
+ if ((allowed.length === 0 || hookNamesIt) && leadsOutside(home, file) !== undefined) return [];
510
538
 
511
539
  let present;
512
540
  try {
@@ -519,15 +547,17 @@ function claudeTrouble(home, bot, allowed) {
519
547
  return [
520
548
  ...present.filter((rule) => !allowed.includes(rule)).map((rule) => ({
521
549
  where: file,
522
- // One `--allow` would refuse was added by the user by hand, which the
550
+ // One `permission allow` would refuse was added by the user by hand, which the
523
551
  // boundary leaves to them: named, in neutral words, and left alone.
524
552
  says: broadness(rule) === undefined
525
- ? `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk bot change --allow; if not, take it out of the file.`
553
+ ? `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk permission allow; if not, take it out of the file.`
526
554
  : `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
527
555
  })),
528
- ...allowed.filter((rule) => !present.includes(rule)).map((rule) => ({
556
+ // One finding for all the rules it lacks: with the kit's default set, a
557
+ // file written by hand can lack forty.
558
+ ...missingIn(allowed.filter((rule) => !present.includes(rule)), (missing) => ({
529
559
  where: file,
530
- says: `${file} does not hold ${rule}, which ${bot.name}'s bot.yaml allows, so ${bot.name}'s Claude sessions are asked about it. obk up writes it.`,
560
+ says: `${file} does not hold ${missing.length === 1 ? missing[0] : `these ${missing.length} rules`}, which ${bot.name}'s bot.yaml allows, so ${bot.name}'s Claude sessions are asked about ${missing.length === 1 ? 'it' : `them: ${missing.join(', ')}`}. obk up writes ${missing.length === 1 ? 'it' : 'them'}.`,
531
561
  })),
532
562
  ];
533
563
  }
@@ -597,6 +627,9 @@ function codexTrouble(home, bot) {
597
627
  }];
598
628
  }
599
629
 
630
+ /** One finding about `missing`, made by `finding`, or none when nothing is missing. */
631
+ const missingIn = (missing, finding) => (missing.length === 0 ? [] : [finding([...new Set(missing)])]);
632
+
600
633
  /** The file's `permissions.allow`, or a refusal when it is there and is not a list. */
601
634
  function presentIn(settings, file) {
602
635
  const permissions = settings.permissions;
package/src/temp.js CHANGED
@@ -34,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
34
34
  import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
35
35
  import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
36
36
  import { untrustedKitHooks } from './hook-trust.js';
37
- import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
37
+ import { approvalRank, DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, refuseApprovalArgs, shellWord, startPrompt, workDirOf } from './launch.js';
38
38
  import { findSession, sessionInTab } from './message.js';
39
39
  import { orca, screenRows, tabs } from './orca.js';
40
40
  import { retireSession } from './retire.js';
@@ -75,6 +75,8 @@ export async function makeTemp(bots, { tab, ...given }) {
75
75
 
76
76
  const maker = bot.sessions.find((session) => session.name === caller.session);
77
77
  const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
78
+ refuseWiderApproval(bots, caller, bot, maker, given.approval);
79
+ refuseApprovalArgs(chosen.harness.value, given.extra_args);
78
80
  settings.name = name;
79
81
  if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
80
82
  if (given.prompt !== undefined) settings.prompt = given.prompt;
@@ -173,6 +175,22 @@ function settingsFor(bot, maker, option, given) {
173
175
  return { settings, chosen };
174
176
  }
175
177
 
178
+ /**
179
+ * Refuse an approval wider than the widest of the maker's own and the bot's
180
+ * `temp_approval`, which only the user's yes through `permission approval
181
+ * --temps` widens (ADR 0041).
182
+ */
183
+ function refuseWiderApproval(bots, caller, bot, maker, asked) {
184
+ if (asked === undefined) return;
185
+ // A blank level is the kit's default at launch (ADR 0015), so it is judged as that.
186
+ const effective = (given) => (given === undefined || given === null || String(given).trim() === '' ? DEFAULT_APPROVAL : given);
187
+ const level = effective(asked);
188
+ const own = effective(maker?.approval);
189
+ const widest = approvalRank(bot.temp_approval) > approvalRank(own) ? bot.temp_approval : own;
190
+ if (approvalRank(level) <= approvalRank(widest)) return;
191
+ throw new Error(`--approval ${level} is wider than ${caller.session}'s own approval, ${own}${widest === own ? '' : `, and than ${caller.bot}'s temp_approval, ${widest}`}, and a session does not widen another's approval. The user allows it for ${caller.bot}'s temporary sessions with ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${caller.bot} --temps --approval ${level}. Nothing was made.`);
192
+ }
193
+
176
194
  /**
177
195
  * The role and option `asked` names, as `role` or `role:option`, from the
178
196
  * bot's roles, with the option it takes; or a refusal that names what the bot