@assuredloop/orca-bot-kit 0.25.6 → 0.26.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/src/cli.js CHANGED
@@ -12,15 +12,16 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS, setRoleCaps, setTempApproval } from './bot.js';
16
+ import { COMMANDS } from './commands.js';
16
17
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
18
  import { checkHealth, orcaSettingFindings } from './health.js';
18
19
  import { initBots } from './init.js';
19
20
  import { nameSession } from './name.js';
20
- import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
21
+ import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
21
22
  import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab, stillUnread, WATCH_MS } from './message.js';
22
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
23
- import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
25
  import { pauseSessions, unpauseSessions } from './pause.js';
25
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
26
27
  import { restartSessions } from './restart.js';
@@ -48,11 +49,12 @@ Usage:
48
49
  opened in Orca until you run obk up.
49
50
  obk session add --bots <path> --bot <bot> --name <session>
50
51
  [--harness claude|codex] [--model <m>] [--effort <e>]
51
- [--context <c>] [--approval ${APPROVALS.join('|')}]
52
+ [--context <c>]
52
53
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
53
54
  [--extra-arg=<arg>]
54
55
  Add a session to a bot. Anything left out is the
55
- harness's own default; approval is auto. A long
56
+ harness's own default; approval is auto, and only
57
+ obk permission approval changes it. A long
56
58
  start prompt lives in a file in the bot home, and
57
59
  --prompt-file names it.
58
60
  A value of your own that starts with a dash is
@@ -60,20 +62,32 @@ Usage:
60
62
  as ours: --prompt='- a bullet', and
61
63
  --extra-arg=--search, once per extra argument.
62
64
  obk bot change --bots <path> --bot <bot> [--charter <text>]
63
- [--allow <rule> ... | --disallow <rule> ...]
65
+ [--role-cap <role>=<n> ...]
64
66
  Give a bot a new charter and rebuild its AGENTS.md.
65
67
  A running session reads it when it next starts.
66
- --allow records a permission rule the user said
67
- yes to, once per rule, and writes it into the bot's
68
- Claude settings. A broad rule, such as Bash(gh:*),
69
- is refused and nothing is written.
70
- --disallow takes back a rule the bot was allowed,
71
- once the user has said yes to that, from bot.yaml
72
- and the bot's settings. A rule the user added by
73
- hand is not the kit's, and is refused.
68
+ --role-cap sets the cap of a role in the bot's
69
+ temp_roles, once per role; <role>= takes it off.
70
+ obk permission allow --bots <path> --bot <bot> --rule <rule> ...
71
+ Record a permission rule the user said yes to,
72
+ once per rule, and write it into the bot's Claude
73
+ settings and Codex rules. A broad rule, such as
74
+ Bash(gh:*), is refused and nothing is written.
75
+ Every bot has the kit's own default rules already,
76
+ with nobody asked.
77
+ obk permission disallow --bots <path> --bot <bot> --rule <rule> ...
78
+ Take back a rule the bot was allowed, once the user
79
+ has said yes to that, from bot.yaml and the bot's
80
+ settings. A rule the user added by hand is not the
81
+ kit's, and a rule of the kit's default set is
82
+ written again, so both are refused.
83
+ obk permission approval --bots <path> --bot <bot>
84
+ (--session <name> | --temps) --approval ${APPROVALS.join('|')}
85
+ Set a session's approval level once the user has
86
+ said yes to it, or with --temps the widest level
87
+ the bot's temporary sessions may be made at. A
88
+ running session takes it when it next starts.
74
89
  obk session change --bots <path> --bot <bot> --session <session>
75
90
  [--model <m>] [--effort <e>] [--context <c>]
76
- [--approval ${APPROVALS.join('|')}]
77
91
  [--prompt <text> | --prompt-file <path>] [--work-dir <path>]
78
92
  [--extra-arg=<arg>]
79
93
  Change a session's settings. What you leave out
@@ -111,7 +125,9 @@ Usage:
111
125
  a time, and one it made makes none. It takes your
112
126
  harness, model, effort, context and approval unless
113
127
  you say otherwise; on another harness than yours
114
- it takes only your approval. It works in
128
+ it takes only your approval. --approval is refused
129
+ wider than yours, unless obk permission approval
130
+ --temps allowed it for the bot. It works in
115
131
  work/<session>, and has the task as its start
116
132
  prompt. The book records it as temporary, made by
117
133
  you.
@@ -313,52 +329,12 @@ Usage:
313
329
  obk --help Print this text.
314
330
 
315
331
  Every command is safe to run again. Most only add what is missing; the ones
316
- that close a tab or take something away say so above.
332
+ that close a tab or take something away say so above. Every bot is allowed
333
+ the kit's commands by default, except obk permission, retire, pause and init,
334
+ which keep the user's yes.
317
335
  Add --json to any of them for the same answer as JSON.
318
336
  `;
319
337
 
320
- /** The commands, and the flags each one cannot do without. */
321
- const COMMANDS = {
322
- init: ['bots', 'harness'],
323
- up: ['bots'],
324
- restart: ['bots', 'bot'],
325
- pause: ['bots', 'bot'],
326
- unpause: ['bots', 'bot'],
327
- retire: ['bots', 'bot'],
328
- health: ['bots'],
329
- groom: ['bots'],
330
- roster: ['bots'],
331
- usage: ['bots'],
332
- 'bot create': ['bots', 'name', 'harness'],
333
- 'bot change': ['bots', 'bot'],
334
- 'rules build': ['bots'],
335
- 'skills add': ['bots', 'bot', 'skill'],
336
- 'skills remove': ['bots', 'bot', 'skill'],
337
- 'skills build': ['bots'],
338
- 'skills fetch': ['bots'],
339
- 'skills update': ['bots'],
340
- 'source add': ['bots', 'name', 'repo', 'ref'],
341
- 'session add': ['bots', 'bot', 'name'],
342
- 'session change': ['bots', 'bot', 'session'],
343
- 'session clear': ['bots', 'bot', 'session'],
344
- 'session compact': ['bots', 'bot', 'session'],
345
- 'message to': ['bots', 'to'],
346
- 'message send': ['bots', 'to', 'subject'],
347
- 'message check': ['bots'],
348
- 'session record': ['bots', 'bot'],
349
- 'session sent': ['bots', 'bot'],
350
- 'session nudge': ['bots', 'bot'],
351
- 'session mail': ['bots', 'bot'],
352
- 'session name': ['bots', 'bot'],
353
- 'session mailbox': ['bots', 'bot', 'session'],
354
- 'session trust-hooks': ['bots', 'bot', 'session'],
355
- 'session answer': ['bots', 'bot', 'session'],
356
- 'temp make': ['bots', 'name'],
357
- 'temp roles': ['bots'],
358
- 'temp retire': ['bots', 'name'],
359
- 'temp trust-hooks': ['bots', 'name'],
360
- 'temp answer': ['bots', 'name'],
361
- };
362
338
 
363
339
  /** What each flag is for, in the sentence a caller reads when it is missing. */
364
340
  const NEEDED = {
@@ -379,10 +355,15 @@ const NEEDED = {
379
355
  from: '--from <bot>/<session>: which session is writing',
380
356
  subject: '--subject <text>: what the message is about',
381
357
  role: '--role <role>[:<option>]: which role of the bot\'s temp_roles',
358
+ rule: '--rule <rule>: the exact permission rule the user said yes to',
359
+ approval: `--approval ${APPROVALS.join('|')}: the approval level the user said yes to`,
382
360
  };
383
361
 
384
- /** The flags that name something. A name that is empty names nothing. */
385
- const IDENTIFIERS = Object.keys(NEEDED);
362
+ /**
363
+ * The flags that name something. A name that is empty names nothing. A rule
364
+ * and an approval are judged by their own commands.
365
+ */
366
+ const IDENTIFIERS = Object.keys(NEEDED).filter((flag) => flag !== 'rule' && flag !== 'approval');
386
367
 
387
368
  /**
388
369
  * The session settings a flag can carry, as `[flag, field]`: every field a
@@ -399,9 +380,10 @@ function version() {
399
380
  }
400
381
 
401
382
  async function run(argv) {
402
- const { values, positionals } = parseArgs({
383
+ const { values, positionals, tokens } = parseArgs({
403
384
  args: argv,
404
385
  allowPositionals: true,
386
+ tokens: true,
405
387
  options: {
406
388
  bots: { type: 'string' },
407
389
  harness: { type: 'string' },
@@ -430,6 +412,9 @@ async function run(argv) {
430
412
  charter: { type: 'string' },
431
413
  allow: { type: 'string', multiple: true },
432
414
  disallow: { type: 'string', multiple: true },
415
+ rule: { type: 'string', multiple: true },
416
+ temps: { type: 'boolean' },
417
+ 'role-cap': { type: 'string', multiple: true },
433
418
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
434
419
  'extra-arg': { type: 'string', multiple: true },
435
420
  'run-on': { type: 'string' },
@@ -454,9 +439,16 @@ async function run(argv) {
454
439
  return 1;
455
440
  }
456
441
 
457
- // `bot`, `rules`, `skills`, `session`, `source`, `message` and `temp` are
458
- // commands of two words; the rest are one.
459
- const words = ['bot', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
442
+ // A permission rule narrows a command to one bots folder by its first words,
443
+ // whatever comes after them (ADR 0036), and the last of two `--bots` would
444
+ // win: so a second one is refused before anything runs.
445
+ if (tokens.filter((token) => token.kind === 'option' && token.name === 'bots').length > 1) {
446
+ throw new Error('--bots is given more than once. Give it once: one command works on one bots folder. Nothing was done.');
447
+ }
448
+
449
+ // `bot`, `permission`, `rules`, `skills`, `session`, `source`, `message` and
450
+ // `temp` are commands of two words; the rest are one.
451
+ const words = ['bot', 'permission', 'rules', 'skills', 'session', 'source', 'message', 'temp'].includes(positionals[0]) ? 2 : 1;
460
452
  const command = positionals.slice(0, words).join(' ');
461
453
  const extra = positionals.slice(words);
462
454
 
@@ -856,7 +848,7 @@ const commands = {
856
848
  answer: { bots, ...answered },
857
849
  lines: [
858
850
  `answered ${answered.bot} ${answered.session}, a temporary session of ${answered.maker}'s: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`,
859
- `rule a maker's bot runs this under one permission rule, which the user approves: Bash(${shellWord(ownCli())} temp answer:*)`,
851
+ `rule a maker's bot runs this under a rule of the kit's default set, which every bot has: Bash(${shellWord(ownCli())} temp answer --bots ${shellWord(bots)}:*)`,
860
852
  ],
861
853
  };
862
854
  },
@@ -889,14 +881,14 @@ const commands = {
889
881
  // A new bot is given what the lists already name, so it is whole before
890
882
  // anybody opens a tab on it.
891
883
  const skills = [linkSkills(bots, made.home, readBot(made.home))];
892
- // Nothing is allowed yet, so this writes nothing: it says which of the
893
- // kit's rules wait for the user's yes (#344).
894
- const permissions = writePermissions(bots, made.home, readBot(made.home));
895
- const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
896
884
  // A bot whose rules would not build is made but not finished: it has no
897
885
  // instructions, so `up` will not start it, and saying "give it a session"
898
886
  // would send the caller past the thing that needs settling first.
899
887
  const trouble = rules[0].trouble !== undefined;
888
+ // The kit's default rules (ADR 0036), for a bot that is set up to run: one
889
+ // with no instructions gets them at the rules build that gives it some.
890
+ const permissions = trouble ? [] : writePermissions(bots, made.home, readBot(made.home));
891
+ const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
900
892
  return {
901
893
  answer,
902
894
  lines: [
@@ -916,29 +908,18 @@ const commands = {
916
908
  if (values.harness !== undefined) {
917
909
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
918
910
  }
919
- if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
920
- throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
921
- }
922
- if (values.allow !== undefined && values.disallow !== undefined) {
923
- throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
911
+ // Permission changes have commands of their own, which keep the user's yes
912
+ // (ADR 0037): the old spelling refuses, and changes nothing.
913
+ const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
914
+ if (moved.length > 0) {
915
+ throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
924
916
  }
925
- // Refused before anything is written, so a bad rule, or a bad list already
926
- // there, leaves the charter as it was too.
927
- let allowInto;
928
- if (values.allow !== undefined) {
929
- const { home } = allowedNow(bots, values.bot, values.allow);
930
- refuseBroad(home, values.allow);
931
- const bot = readBot(home, values.bot);
932
- refuseNoCodexForm(bot, values.allow);
933
- const { allow } = allowRules(bots, values.bot, values.allow, { write: false });
934
- allowInto = allowIn(bots, home, { ...bot, allow });
935
- }
936
- let takeBackFrom;
937
- if (values.disallow !== undefined) {
938
- const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
939
- takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
940
- disallowRules(bots, values.bot, values.disallow, { write: false });
917
+ if (values.charter === undefined && values['role-cap'] === undefined) {
918
+ throw new Error('bot change needs --charter <text> or --role-cap <role>=<n>: what to change.');
941
919
  }
920
+ // Refused before anything is written, so a bad cap leaves the charter as it was too.
921
+ const caps = values['role-cap'] === undefined ? undefined : values['role-cap'].map(roleCap);
922
+ if (caps !== undefined) setRoleCaps(bots, values.bot, caps.map(({ role, cap }) => [role, cap]), { write: false });
942
923
 
943
924
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
944
925
  const lines = [];
@@ -962,7 +943,7 @@ const commands = {
962
943
  // An allow list that is not a list is named by rules build and health,
963
944
  // and a charter already written is not undone for it.
964
945
  const bot = readBot(changed.home, changed.bot);
965
- if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
946
+ if (runsOnClaude(bot) || runsOnCodex(bot)) {
966
947
  try {
967
948
  answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
968
949
  lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
@@ -971,15 +952,31 @@ const commands = {
971
952
  }
972
953
  }
973
954
  }
974
- if (values.allow !== undefined) {
975
- // Written at once, as the charter's rules are built at once (#344): the
976
- // harness's files first, bot.yaml last, so a write that fails leaves
977
- // `allow` as it was, and the same command can run again (#383).
978
- const permissions = allowInto();
979
- const allowed = allowRules(bots, values.bot, values.allow);
980
- Object.assign(answer, { allow: allowed.allow, permissions });
981
- const codex = permissions.find((entry) => entry.unwritten !== undefined);
982
- lines.push(
955
+ if (caps !== undefined) {
956
+ setRoleCaps(bots, values.bot, caps.map(({ role, cap }) => [role, cap]));
957
+ answer.caps = Object.fromEntries(caps.map(({ role, cap }) => [role, cap ?? null]));
958
+ lines.push(...caps.map(({ role, cap }) => (cap === undefined
959
+ ? `changed the role ${role} in ${path.join('bots', values.bot, 'bot.yaml')}: no cap`
960
+ : `changed the role ${role} in ${path.join('bots', values.bot, 'bot.yaml')}: cap ${cap}`)));
961
+ }
962
+ return { answer, lines, code: trouble ? 1 : 0 };
963
+ },
964
+
965
+ 'permission allow'(bots, values) {
966
+ // Refused before anything is written, a bad list already there included.
967
+ const { home } = allowedNow(bots, values.bot, values.rule);
968
+ refuseBroad(home, values.rule);
969
+ const bot = readBot(home, values.bot);
970
+ refuseNoCodexForm(bot, values.rule);
971
+ const { allow } = allowRules(bots, values.bot, values.rule, { write: false });
972
+ // The harness's files first, bot.yaml last, so a write that fails leaves
973
+ // `allow` as it was, and the same command can run again (#383).
974
+ const permissions = allowIn(bots, home, { ...bot, allow })();
975
+ const allowed = allowRules(bots, values.bot, values.rule);
976
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
977
+ return {
978
+ answer: { bots, bot: values.bot, home, allow: allowed.allow, permissions },
979
+ lines: [
983
980
  ...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
984
981
  ...permissionsLines(permissions, bots),
985
982
  // Codex reads its rules when a session starts, not while it runs.
@@ -989,16 +986,22 @@ const commands = {
989
986
  allowed.added.length === 0
990
987
  ? `${allowed.bot} was allowed every one of these already.`
991
988
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
992
- );
993
- }
994
- if (values.disallow !== undefined) {
995
- // The harness's files first, bot.yaml last: a write that fails leaves the
996
- // rules in `allow`, so the same command can take them back again (#360).
997
- const permissions = takeBackFrom();
998
- const taken = disallowRules(bots, values.bot, values.disallow);
999
- Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
1000
- const codex = permissions.find((entry) => entry.unwritten !== undefined);
1001
- lines.push(
989
+ ],
990
+ };
991
+ },
992
+
993
+ 'permission disallow'(bots, values) {
994
+ const { home } = allowedNow(bots, values.bot, values.rule);
995
+ const takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.rule);
996
+ disallowRules(bots, values.bot, values.rule, { write: false });
997
+ // The harness's files first, bot.yaml last: a write that fails leaves the
998
+ // rules in `allow`, so the same command can take them back again (#360).
999
+ const permissions = takeBackFrom();
1000
+ const taken = disallowRules(bots, values.bot, values.rule);
1001
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
1002
+ return {
1003
+ answer: { bots, bot: values.bot, home, allow: taken.allow, disallowed: taken.disallowed, permissions },
1004
+ lines: [
1002
1005
  ...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
1003
1006
  ...permissions.filter((entry) => entry.removed?.length > 0).map((entry) => `${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.removed.length} permission rule${entry.removed.length === 1 ? '' : 's'} taken out`),
1004
1007
  ...permissionsLines(permissions, bots),
@@ -1006,9 +1009,36 @@ const commands = {
1006
1009
  `Codex reads ${codex.file} when a session starts: a Codex session of ${taken.bot} that is running now keeps ${codex.removed.length === 1 ? 'this rule' : 'these rules'} until its next start (obk restart).`,
1007
1010
  ]),
1008
1011
  `${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
1009
- );
1012
+ ],
1013
+ };
1014
+ },
1015
+
1016
+ 'permission approval'(bots, values) {
1017
+ if ((values.session === undefined) === (values.temps !== true)) {
1018
+ throw new Error('permission approval needs --session <name>, for a session, or --temps, for the bot\'s temporary sessions, and not both. Nothing was changed.');
1010
1019
  }
1011
- return { answer, lines, code: trouble ? 1 : 0 };
1020
+ if (!APPROVALS.includes(values.approval)) {
1021
+ throw new Error(`--approval is ${APPROVALS.join(', ')}, and got: ${values.approval === '' ? 'nothing' : values.approval}. Nothing was changed.`);
1022
+ }
1023
+ if (values.temps) {
1024
+ const set = setTempApproval(bots, values.bot, values.approval);
1025
+ return {
1026
+ answer: { bots, bot: set.bot, home: set.home, temp_approval: set.level },
1027
+ lines: [
1028
+ `changed temp_approval in ${path.join('bots', set.bot, 'bot.yaml')}: ${set.level}`,
1029
+ `${set.bot}'s temporary sessions may now be made at ${set.level}, or at their maker's own level where that is wider.`,
1030
+ ],
1031
+ };
1032
+ }
1033
+ const changed = changeSession(bots, values.bot, values.session, { approval: values.approval });
1034
+ const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
1035
+ return {
1036
+ answer: { bots, bot: changed.bot, home: changed.home, session: changed.session, restart },
1037
+ lines: [
1038
+ `changed session ${values.session} in ${path.join('bots', changed.bot, 'bot.yaml')}: approval ${values.approval}`,
1039
+ `A running session takes this when it next starts: ${restart}`,
1040
+ ],
1041
+ };
1012
1042
  },
1013
1043
 
1014
1044
  'skills build'(bots, values) {
@@ -1086,6 +1116,8 @@ const commands = {
1086
1116
  ...(values.effort === undefined ? {} : { effort: values.effort }),
1087
1117
  ...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
1088
1118
  };
1119
+ // A run's launch may not widen its approval (ADR 0037).
1120
+ if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
1089
1121
  const groom = grooming(bots, { at: values.at, ask, run });
1090
1122
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
1091
1123
  },
@@ -1217,6 +1249,7 @@ const commands = {
1217
1249
  },
1218
1250
 
1219
1251
  'session add'(bots, values) {
1252
+ refuseApproval(bots, values, 'session add');
1220
1253
  const added = addSession(bots, values.bot, settingsOf(values));
1221
1254
  const found = workDirFound(values, added.bot, added.home, added.session.name);
1222
1255
  const answer = { bots, bot: added.bot, home: added.home, session: added.session, found };
@@ -1249,6 +1282,7 @@ const commands = {
1249
1282
  if (values.harness !== undefined) {
1250
1283
  throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
1251
1284
  }
1285
+ refuseApproval(bots, values, 'session change');
1252
1286
  const { name, ...settings } = settingsOf(values);
1253
1287
  const changed = changeSession(bots, values.bot, values.session, settings);
1254
1288
  const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${changed.bot} --session ${values.session}`;
@@ -1371,6 +1405,43 @@ function workDirFound(values, bot, home, session) {
1371
1405
  }];
1372
1406
  }
1373
1407
 
1408
+ /**
1409
+ * Refuse what would change a session's approval through `session add` or
1410
+ * `session change` (ADR 0037): `--approval`, and an extra argument that sets
1411
+ * it on the session's harness. Only `permission approval` changes it.
1412
+ */
1413
+ function refuseApproval(bots, values, command) {
1414
+ if (values.approval !== undefined) {
1415
+ throw new Error(`${command} no longer takes --approval: a session's approval changes through ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${values.bot} --session ${values.session ?? values.name} --approval <level>, after the user's yes. Nothing was changed.`);
1416
+ }
1417
+ if (values['extra-arg'] === undefined) return;
1418
+ let bot;
1419
+ try {
1420
+ bot = readBot(botDir(bots, values.bot), values.bot);
1421
+ } catch {
1422
+ // A bot that is not there is the command's own to refuse.
1423
+ return;
1424
+ }
1425
+ const session = bot.sessions.find((one) => one.name === (values.session ?? values.name)) ?? {};
1426
+ refuseApprovalArgs(harnessOf({ ...session, ...(values.harness === undefined ? {} : { harness: values.harness }) }, bot.harness), values['extra-arg']);
1427
+ }
1428
+
1429
+ /**
1430
+ * One `--role-cap <role>=<n>`, as `{ role, cap }`: a cap of undefined, from
1431
+ * `<role>=`, takes it off. A cap is a whole number of open sessions, 1 or more.
1432
+ */
1433
+ function roleCap(given) {
1434
+ const at = given.indexOf('=');
1435
+ if (at <= 0) throw new Error(`--role-cap is <role>=<n>, such as reviewer=2, or <role>= to take a cap off, and got: ${given}. Nothing was changed.`);
1436
+ const role = given.slice(0, at);
1437
+ const text = given.slice(at + 1);
1438
+ if (text === '') return { role };
1439
+ if (!/^\d+$/.test(text) || Number(text) < 1) {
1440
+ throw new Error(`--role-cap ${given}: a cap is a whole number of open sessions, 1 or more, such as ${role}=2. Nothing was changed.`);
1441
+ }
1442
+ return { role, cap: Number(text) };
1443
+ }
1444
+
1374
1445
  /** The settings a `session add` was given, as they go into bot.yaml. */
1375
1446
  function settingsOf(values) {
1376
1447
  const settings = { name: values.name };
@@ -1702,7 +1773,7 @@ function permissionsOf(bots, name) {
1702
1773
  */
1703
1774
  function charterRulesLines(bots, bot, rules) {
1704
1775
  const name = bot.name;
1705
- const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --allow <rule>`;
1776
+ const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} permission allow --bots ${shellWord(bots)} --bot ${shellWord(name)} --rule <rule>`;
1706
1777
  if (rules.length === 0) return [`${name} is allowed no permission rules beyond the kit's defaults. ${until}`];
1707
1778
  const settings = runsOnClaude(bot) ? ` and ${path.join('bots', name, '.claude', 'settings.json')}` : '';
1708
1779
  // The kit owns the Codex file whole and rewrites it from bot.yaml (#354).
@@ -1710,34 +1781,30 @@ function charterRulesLines(bots, bot, rules) {
1710
1781
  return [
1711
1782
  `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1712
1783
  ...rules.map((rule) => ` ${rule}`),
1713
- `They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --disallow <rule>. ${until}`,
1784
+ `They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} permission disallow --bots ${shellWord(bots)} --bot ${shellWord(name)} --rule <rule>. ${until}`,
1714
1785
  ];
1715
1786
  }
1716
1787
 
1717
1788
  /**
1718
- * What became of each bot's permission rules: the file the allowed ones were
1719
- * written into, and the kit's rules that still wait for the user's yes, each
1720
- * word for word, with the one command that allows them once the user has said
1721
- * it (#344). Nothing for a bot with nothing written and nothing waiting.
1789
+ * What became of each bot's permission rules: each of the kit's default rules
1790
+ * the run added to the bot, word for word (ADR 0036), and the file the allowed
1791
+ * ones were written into. Nothing for a bot with nothing added or written.
1722
1792
  */
1723
1793
  function permissionsLines(permissions, bots) {
1724
- // A bot on both harnesses has an entry for each file, and what waits for
1725
- // Codex is also waiting for Claude: the rules are listed once, with the first.
1794
+ // A bot on both harnesses has an entry for each file, with the same
1795
+ // defaults: they are listed once, with the first.
1726
1796
  const listed = new Set();
1727
1797
  return permissions.flatMap((entry) => [
1728
1798
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1799
+ ...((entry.defaults ?? []).length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? []
1800
+ : entry.defaults.map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`)),
1729
1801
  ...(entry.written.length === 0
1730
1802
  ? []
1731
- : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} the user allowed`]),
1803
+ : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} from its allow list`]),
1732
1804
  // What the user allowed and Codex has no form for is said at every build,
1733
1805
  // so a bot given a Codex session later hears of it too (#354). For a bot
1734
- // only on Codex, `--allow` refused it already.
1806
+ // only on Codex, `permission allow` refused it already.
1735
1807
  ...(entry.unwritten ?? []).map((one) => `${'not'.padEnd(9)} written for Codex: ${one.rule}. ${one.why}.`),
1736
- ...(entry.waiting.length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1737
- `${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
1738
- ...entry.waiting.map((rule) => ` ${rule}`),
1739
- ` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
1740
- ]),
1741
1808
  ]);
1742
1809
  }
1743
1810
 
@@ -0,0 +1,74 @@
1
+ // The kit's commands, each with the flags it cannot do without, which `obk`
2
+ // reads to parse a command line; and the two lists every command is on one of:
3
+ // those every bot is allowed by default (#527, ADR 0036), and those kept back
4
+ // for the user's yes (ADR 0037). A new command is on neither until someone
5
+ // puts it on one, and a test holds every command to that choice.
6
+
7
+ /** The commands, and the flags each one cannot do without. */
8
+ export const COMMANDS = {
9
+ init: ['bots', 'harness'],
10
+ up: ['bots'],
11
+ restart: ['bots', 'bot'],
12
+ pause: ['bots', 'bot'],
13
+ unpause: ['bots', 'bot'],
14
+ retire: ['bots', 'bot'],
15
+ health: ['bots'],
16
+ groom: ['bots'],
17
+ roster: ['bots'],
18
+ usage: ['bots'],
19
+ 'bot create': ['bots', 'name', 'harness'],
20
+ 'bot change': ['bots', 'bot'],
21
+ 'permission allow': ['bots', 'bot', 'rule'],
22
+ 'permission disallow': ['bots', 'bot', 'rule'],
23
+ 'permission approval': ['bots', 'bot', 'approval'],
24
+ 'rules build': ['bots'],
25
+ 'skills add': ['bots', 'bot', 'skill'],
26
+ 'skills remove': ['bots', 'bot', 'skill'],
27
+ 'skills build': ['bots'],
28
+ 'skills fetch': ['bots'],
29
+ 'skills update': ['bots'],
30
+ 'source add': ['bots', 'name', 'repo', 'ref'],
31
+ 'session add': ['bots', 'bot', 'name'],
32
+ 'session change': ['bots', 'bot', 'session'],
33
+ 'session clear': ['bots', 'bot', 'session'],
34
+ 'session compact': ['bots', 'bot', 'session'],
35
+ 'message to': ['bots', 'to'],
36
+ 'message send': ['bots', 'to', 'subject'],
37
+ 'message check': ['bots'],
38
+ 'session record': ['bots', 'bot'],
39
+ 'session sent': ['bots', 'bot'],
40
+ 'session nudge': ['bots', 'bot'],
41
+ 'session mail': ['bots', 'bot'],
42
+ 'session name': ['bots', 'bot'],
43
+ 'session mailbox': ['bots', 'bot', 'session'],
44
+ 'session trust-hooks': ['bots', 'bot', 'session'],
45
+ 'session answer': ['bots', 'bot', 'session'],
46
+ 'temp make': ['bots', 'name'],
47
+ 'temp roles': ['bots'],
48
+ 'temp retire': ['bots', 'name'],
49
+ 'temp trust-hooks': ['bots', 'name'],
50
+ 'temp answer': ['bots', 'name'],
51
+ };
52
+
53
+ /**
54
+ * The commands every bot is allowed by default, each narrowed to its bots
55
+ * folder (ADR 0036). Only a command named here is in the default set; one that
56
+ * runs whatever its arguments say never is.
57
+ */
58
+ export const DEFAULT_COMMANDS = [
59
+ 'up', 'restart', 'unpause', 'health', 'groom', 'roster', 'usage',
60
+ 'bot create', 'bot change', 'rules build',
61
+ 'skills add', 'skills remove', 'skills build', 'skills fetch', 'skills update', 'source add',
62
+ 'session add', 'session change', 'session clear', 'session compact',
63
+ 'message to', 'message send', 'message check',
64
+ 'session record', 'session sent', 'session nudge', 'session mail', 'session name', 'session mailbox',
65
+ 'session trust-hooks', 'session answer',
66
+ 'temp make', 'temp roles', 'temp retire', 'temp trust-hooks', 'temp answer',
67
+ ];
68
+
69
+ /**
70
+ * The commands kept out of the default permission set, which keep the user's
71
+ * yes (ADR 0037): the ones that change permissions, close a long-lived
72
+ * session, or make the bots folder.
73
+ */
74
+ export const KEPT_BACK = ['init', 'retire', 'pause', 'permission allow', 'permission disallow', 'permission approval'];
package/src/health.js CHANGED
@@ -25,7 +25,7 @@ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
28
+ import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
29
29
  import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
31
  import { TAB_ENV } from './record.js';
@@ -213,10 +213,16 @@ function sessionSettings(home, bot) {
213
213
  /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
214
214
  function rolesTrouble(home, bot) {
215
215
  const where = path.join(home, 'bot.yaml');
216
+ // The widest approval they may be made at, the user's yes (ADR 0037).
217
+ const level = bot.temp_approval;
218
+ const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
219
+ where,
220
+ says: `${where} has a temp_approval of ${JSON.stringify(level)}, and the levels are ${APPROVALS.join(', ')}, so obk temp make holds the bot's temporary sessions to their maker's own approval. Set it with obk permission approval --temps.`,
221
+ }];
216
222
  try {
217
- return tempRoles(home, bot).flatMap((role) => role.trouble.map((says) => ({ where, says })));
223
+ return [...tempRoles(home, bot).flatMap((role) => role.trouble.map((says) => ({ where, says }))), ...approval];
218
224
  } catch (error) {
219
- return [{ where, says: error.message }];
225
+ return [{ where, says: error.message }, ...approval];
220
226
  }
221
227
  }
222
228